# iOS 12.2 Compatibility Rewrite > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Rewrite DuooomiBleSDK to support iOS 12.2+ by replacing all async/await, Combine, AsyncStream, and Swift Concurrency APIs with callback-based alternatives. **Architecture:** Replace async/await with completion handlers (`Result`), replace `@Published`/`ObservableObject` with a delegate protocol, replace `AsyncStream` with delegate callbacks, replace `Task.sleep` with `DispatchQueue.asyncAfter`, replace `withThrowingTaskGroup` timeout pattern with `DispatchWorkItem` + timer. **Tech Stack:** Swift 5.0, CoreBluetooth, Foundation, GCD (DispatchQueue/DispatchWorkItem) --- ## File Structure ### Files to Modify (no path change) | File | Changes | |------|---------| | `Sources/DuooomiBleSDK/DuooomiBleConfig.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Core/BleTypes.swift` | Remove `Sendable` from all types | | `Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift` | Remove `Sendable` from `CommandType` | | `Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Protocol/ProtocolManager.swift` | No changes needed (pure functions, no async) | | `Sources/DuooomiBleSDK/Utils/BleLogger.swift` | Remove `@autoclosure` if needed for compatibility | | `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Models/VersionInfo.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Models/BindingResponse.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Models/UnbindResponse.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift` | Remove `Sendable` | | `Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift` | Remove `Sendable` | ### Files to Rewrite (same path, major changes) | File | Changes | |------|---------| | `Sources/DuooomiBleSDK/DuooomiBleSDK.swift` | Remove `@MainActor`, `ObservableObject`, `@Published`, all `async throws` methods → completion handlers, `withThrowingTaskGroup` → DispatchWorkItem+timer, `Task` → GCD | | `Sources/DuooomiBleSDK/Core/BleClient.swift` | Remove `@unchecked Sendable`, `AsyncStream` → delegate, `CheckedContinuation` → closures | | `Sources/DuooomiBleSDK/Protocol/BleProtocolService.swift` | Remove `@unchecked Sendable`, `AsyncStream` → delegate, `Task.sleep` → `DispatchQueue.asyncAfter`, async send → callback | | `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` | `async throws` → completion handlers | | `Sources/DuooomiBleSDK/Services/FileTransferService.swift` | `async throws` → completion, `URLSession.data(for:)` → `dataTask` | | `Sources/DuooomiBleSDK/Services/AniConverter.swift` | `async throws` → completion, `URLSession.data(for:)` → `dataTask` | | `Sources/DuooomiBleSDK/Services/FirmwareService.swift` | `async throws` → completion, `URLSession.data(for:)` → `dataTask` | ### New Files | File | Purpose | |------|---------| | `Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift` | Delegate protocol replacing `@Published` observability | ### Config Files to Update | File | Changes | |------|---------| | `Package.swift` | `.iOS(.v12)` (Note: SPM supports `.iOS(.v12)`) | | `project.yml` | `deploymentTarget: iOS: "12.2"` | | `DuooomiBleSDK.podspec` | `s.ios.deployment_target = '12.2'` | | `README.md` | Update minimum version, API examples to callback style | --- ## Task 1: Remove Sendable from All Model & Config Types **Files:** - Modify: `Sources/DuooomiBleSDK/DuooomiBleConfig.swift` - Modify: `Sources/DuooomiBleSDK/Core/BleTypes.swift` - Modify: `Sources/DuooomiBleSDK/Protocol/ProtocolConstants.swift` - Modify: `Sources/DuooomiBleSDK/Protocol/ProtocolFrame.swift` - Modify: `Sources/DuooomiBleSDK/Models/DeviceInfo.swift` - Modify: `Sources/DuooomiBleSDK/Models/VersionInfo.swift` - Modify: `Sources/DuooomiBleSDK/Models/BindingResponse.swift` - Modify: `Sources/DuooomiBleSDK/Models/UnbindResponse.swift` - Modify: `Sources/DuooomiBleSDK/Models/DeleteFileResponse.swift` - Modify: `Sources/DuooomiBleSDK/Models/PrepareTransferResponse.swift` - Modify: `Sources/DuooomiBleSDK/Services/FirmwareService.swift` (FirmwareInfo struct only) - [ ] **Step 1: DuooomiBleConfig.swift — Remove Sendable** ```swift // Before: public struct DuooomiBleConfig: Sendable { // After: public struct DuooomiBleConfig { ``` - [ ] **Step 2: BleTypes.swift — Remove Sendable from all types** ```swift // Before: public enum ConnectionState: String, Sendable { // After: public enum ConnectionState: String { // Before: public struct DiscoveredDevice: Identifiable, Sendable, Equatable { // After: public struct DiscoveredDevice: Identifiable, Equatable { // Before: public enum DuooomiBleError: LocalizedError, Sendable { // After: public enum DuooomiBleError: LocalizedError { ``` - [ ] **Step 3: ProtocolConstants.swift — Remove Sendable from CommandType** ```swift // Before: public enum CommandType: UInt8, Sendable { // After: public enum CommandType: UInt8 { ``` - [ ] **Step 4: ProtocolFrame.swift — Remove Sendable** ```swift // Before: public struct ProtocolFrame: Sendable { // After: public struct ProtocolFrame { ``` - [ ] **Step 5: All model files — Remove Sendable** `DeviceInfo.swift`: ```swift // Before: public struct DeviceInfo: Sendable, Equatable { // After: public struct DeviceInfo: Equatable { ``` `VersionInfo.swift`: ```swift // Before: public struct VersionInfo: Sendable, Equatable { // After: public struct VersionInfo: Equatable { ``` `BindingResponse.swift`: ```swift // Before: public struct BindingResponse: Codable, Sendable, Equatable { // After: public struct BindingResponse: Codable, Equatable { ``` `UnbindResponse.swift`: ```swift // Before: public struct UnbindResponse: Codable, Sendable, Equatable { // After: public struct UnbindResponse: Codable, Equatable { ``` `DeleteFileResponse.swift`: ```swift // Before: public struct DeleteFileResponse: Codable, Sendable, Equatable { // After: public struct DeleteFileResponse: Codable, Equatable { ``` `PrepareTransferResponse.swift`: ```swift // Before: public struct PrepareTransferResponse: Codable, Sendable, Equatable { // After: public struct PrepareTransferResponse: Codable, Equatable { ``` `FirmwareService.swift` (FirmwareInfo struct at top): ```swift // Before: public struct FirmwareInfo: Codable, Equatable, Sendable { // After: public struct FirmwareInfo: Codable, Equatable { ``` - [ ] **Step 6: Commit** ```bash git add Sources/DuooomiBleSDK/ git commit -m "refactor: remove Sendable conformance for iOS 12.2 compat" ``` --- ## Task 2: Create Delegate Protocol **Files:** - Create: `Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift` - [ ] **Step 1: Create delegate protocol file** ```swift import Foundation /// SDK 状态变化回调协议,替代 Combine @Published 观察。 /// 所有方法在主线程调用。 public protocol DuooomiBleSDKDelegate: AnyObject { /// 蓝牙连接状态变化 func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) /// 扫描到的设备列表更新 func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) /// 已连接设备变化 func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) /// 设备信息更新 func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) /// 固件版本更新 func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) /// 激活状态变化 func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) /// 传输进度更新 (0-100) func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) /// 错误发生 func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) } /// 所有 delegate 方法提供默认空实现,调用方只需实现关心的方法。 public extension DuooomiBleSDKDelegate { func sdk(_ sdk: DuooomiBleSDK, didChangeState state: ConnectionState) {} func sdk(_ sdk: DuooomiBleSDK, didUpdateDevices devices: [DiscoveredDevice]) {} func sdk(_ sdk: DuooomiBleSDK, didConnectDevice device: DiscoveredDevice?) {} func sdk(_ sdk: DuooomiBleSDK, didUpdateDeviceInfo info: DeviceInfo?) {} func sdk(_ sdk: DuooomiBleSDK, didUpdateVersion version: String) {} func sdk(_ sdk: DuooomiBleSDK, didChangeActivation isActivated: Bool) {} func sdk(_ sdk: DuooomiBleSDK, didUpdateProgress progress: Int) {} func sdk(_ sdk: DuooomiBleSDK, didEncounterError error: String?) {} } ``` - [ ] **Step 2: Commit** ```bash git add Sources/DuooomiBleSDK/DuooomiBleSDKDelegate.swift git commit -m "feat: add DuooomiBleSDKDelegate protocol" ``` --- ## Task 3: Rewrite BleClient (AsyncStream + CheckedContinuation → Delegate + Closures) **Files:** - Modify: `Sources/DuooomiBleSDK/Core/BleClient.swift` - [ ] **Step 1: Rewrite BleClient** Replace the entire file content with: ```swift import CoreBluetooth import Foundation /// BLE 客户端内部回调协议 protocol BleClientDelegate: AnyObject { func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) func bleClient(_ client: BleClient, didReceiveData data: Data) } final class BleClient: NSObject { weak var delegate: BleClientDelegate? var onConnectionStateChange: ((ConnectionState) -> Void)? var onDisconnected: (() -> Void)? private let queue = DispatchQueue(label: "com.duooomi.ble.client", qos: .userInitiated) private var centralManager: CBCentralManager! private var connectedPeripheral: CBPeripheral? private var writeCharacteristic: CBCharacteristic? private var readCharacteristic: CBCharacteristic? // Connect/Disconnect callbacks private var connectCompletion: ((Result) -> Void)? private var disconnectCompletion: ((Result) -> Void)? private var connectTimeoutWork: DispatchWorkItem? var bluetoothState: CBManagerState { centralManager.state } var maximumWriteLength: Int { connectedPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 182 } override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: queue) } // MARK: - Scan func scan(serviceUUIDs: [CBUUID] = [BleUUIDs.service]) { guard centralManager.state == .poweredOn else { BleLog.w("Bluetooth not powered on", "BLE") return } centralManager.scanForPeripherals( withServices: serviceUUIDs, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } func stopScan() { centralManager.stopScan() } // MARK: - Connect func connect(deviceId: String, timeout: TimeInterval = 30, completion: @escaping (Result) -> Void) { guard centralManager.state == .poweredOn else { completion(.failure(DuooomiBleError.bluetoothNotPoweredOn)) return } let peripherals = centralManager.retrievePeripherals(withIdentifiers: [UUID(uuidString: deviceId)!]) guard let peripheral = peripherals.first else { completion(.failure(DuooomiBleError.connectionFailed("Device not found: \(deviceId)"))) return } self.connectCompletion = completion peripheral.delegate = self centralManager.connect(peripheral, options: nil) // Timeout let work = DispatchWorkItem { [weak self] in guard let self = self, self.connectCompletion != nil else { return } self.centralManager.cancelPeripheralConnection(peripheral) let cb = self.connectCompletion self.connectCompletion = nil cb?(.failure(DuooomiBleError.connectionFailed("Connection timeout"))) } connectTimeoutWork = work queue.asyncAfter(deadline: .now() + timeout, execute: work) } // MARK: - Disconnect func disconnect(completion: @escaping (Result) -> Void) { guard let peripheral = connectedPeripheral else { completion(.success(())) return } disconnectCompletion = completion centralManager.cancelPeripheralConnection(peripheral) } // MARK: - Write func writeWithoutResponse(_ data: Data) throws { guard let peripheral = connectedPeripheral, let char = writeCharacteristic else { throw DuooomiBleError.notConnected } peripheral.writeValue(data, for: char, type: .withoutResponse) } // MARK: - Retrieve Connected func retrieveConnectedDevices() -> [DiscoveredDevice] { centralManager.retrieveConnectedPeripherals(withServices: [BleUUIDs.service]).map { DiscoveredDevice(id: $0.identifier.uuidString, name: $0.name, rssi: 0) } } } // MARK: - CBCentralManagerDelegate extension BleClient: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { BleLog.i("Bluetooth state: \(central.state.rawValue)", "BLE") } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let device = DiscoveredDevice( id: peripheral.identifier.uuidString, name: peripheral.name, rssi: RSSI.intValue ) delegate?.bleClient(self, didDiscoverDevice: device) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { connectTimeoutWork?.cancel() connectTimeoutWork = nil connectedPeripheral = peripheral peripheral.delegate = self peripheral.discoverServices([BleUUIDs.service]) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { connectTimeoutWork?.cancel() connectTimeoutWork = nil let cb = connectCompletion connectCompletion = nil cb?(.failure(error ?? DuooomiBleError.connectionFailed("Unknown"))) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { connectedPeripheral = nil writeCharacteristic = nil readCharacteristic = nil if let cb = disconnectCompletion { disconnectCompletion = nil if let error = error { cb(.failure(error)) } else { cb(.success(())) } } DispatchQueue.main.async { [weak self] in self?.onDisconnected?() } } } // MARK: - CBPeripheralDelegate extension BleClient: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { let cb = connectCompletion connectCompletion = nil cb?(.failure(error)) return } guard let service = peripheral.services?.first(where: { $0.uuid == BleUUIDs.service }) else { let cb = connectCompletion connectCompletion = nil cb?(.failure(DuooomiBleError.serviceNotFound)) return } peripheral.discoverCharacteristics( [BleUUIDs.writeCharacteristic, BleUUIDs.readCharacteristic], for: service ) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { let cb = connectCompletion connectCompletion = nil cb?(.failure(error)) return } guard let characteristics = service.characteristics else { let cb = connectCompletion connectCompletion = nil cb?(.failure(DuooomiBleError.characteristicNotFound)) return } for char in characteristics { if char.uuid == BleUUIDs.writeCharacteristic { writeCharacteristic = char } else if char.uuid == BleUUIDs.readCharacteristic { readCharacteristic = char peripheral.setNotifyValue(true, for: char) } } guard writeCharacteristic != nil, readCharacteristic != nil else { let cb = connectCompletion connectCompletion = nil cb?(.failure(DuooomiBleError.characteristicNotFound)) return } let cb = connectCompletion connectCompletion = nil cb?(.success(peripheral)) } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let data = characteristic.value else { return } delegate?.bleClient(self, didReceiveData: data) } } ``` - [ ] **Step 2: Build to check compile** Run: `xcodebuild build -workspace demo.xcworkspace -scheme DuooomiBleSDK -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|BUILD"` Expected: Errors in files that depend on old BleClient API (expected at this stage) - [ ] **Step 3: Commit** ```bash git add Sources/DuooomiBleSDK/Core/BleClient.swift git commit -m "refactor: rewrite BleClient with delegate+closures (iOS 12.2)" ``` --- ## Task 4: Rewrite BleProtocolService (AsyncStream → Delegate, Task.sleep → GCD) **Files:** - Modify: `Sources/DuooomiBleSDK/Protocol/BleProtocolService.swift` - [ ] **Step 1: Rewrite BleProtocolService** Replace the entire file content with: ```swift import Foundation /// 协议服务内部回调 protocol BleProtocolServiceDelegate: AnyObject { func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) } final class BleProtocolService: BleClientDelegate { weak var delegate: BleProtocolServiceDelegate? private let client: BleClient private var isListening = false /// Fragment reassembly buffer: key = "\(commandType)" → accumulated data private var fragmentBuffer: [String: Data] = [:] private var fragmentExpected: [String: Int] = [:] init(client: BleClient) { self.client = client } func startListening() { isListening = true client.delegate = self } func stopListening() { isListening = false fragmentBuffer.removeAll() fragmentExpected.removeAll() } // MARK: - Send func send(type: UInt8, data: Data, onProgress: ((Double) -> Void)? = nil, completion: @escaping (Result) -> Void) { let frames = ProtocolManager.createFrames(type: type, data: data) let total = frames.count func sendFrame(at index: Int) { guard index < total else { onProgress?(1.0) completion(.success(())) return } do { try client.writeWithoutResponse(frames[index]) } catch { completion(.failure(error)) return } let progress = Double(index + 1) / Double(total) onProgress?(progress) if index + 1 < total { // Inter-frame delay 35ms DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.035) { sendFrame(at: index + 1) } } else { onProgress?(1.0) completion(.success(())) } } sendFrame(at: 0) } func sendJSON(type: CommandType, payload: T, completion: @escaping (Result) -> Void) { do { let data = try JSONEncoder().encode(payload) send(type: type.rawValue, data: data, completion: completion) } catch { completion(.failure(error)) } } // Synchronous version for fire-and-forget commands func sendJSON(type: CommandType, payload: T) throws { let data = try JSONEncoder().encode(payload) let frames = ProtocolManager.createFrames(type: type.rawValue, data: data) for frame in frames { try client.writeWithoutResponse(frame) } } // MARK: - BleClientDelegate func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { // Not handled here — forwarded by SDK } func bleClient(_ client: BleClient, didReceiveData data: Data) { guard isListening else { return } guard let frame = ProtocolManager.parseFrame(data) else { BleLog.w("Failed to parse frame (\(data.count) bytes)", "Protocol") return } let key = "\(frame.type)" if frame.subpageTotal <= 1 { // Single frame delegate?.protocolService(self, didReceiveMessage: frame.type, data: frame.data) } else { // Multi-frame reassembly if fragmentBuffer[key] == nil { fragmentBuffer[key] = Data() fragmentExpected[key] = Int(frame.subpageTotal) } fragmentBuffer[key]?.append(frame.data) // curPage counts down: last fragment is curPage == 0 if frame.curPage == 0 { if let assembled = fragmentBuffer.removeValue(forKey: key) { fragmentExpected.removeValue(forKey: key) delegate?.protocolService(self, didReceiveMessage: frame.type, data: assembled) } } } } } ``` - [ ] **Step 2: Commit** ```bash git add Sources/DuooomiBleSDK/Protocol/BleProtocolService.swift git commit -m "refactor: rewrite BleProtocolService with delegate+GCD (iOS 12.2)" ``` --- ## Task 5: Rewrite DeviceInfoService (async → fire-and-forget) **Files:** - Modify: `Sources/DuooomiBleSDK/Services/DeviceInfoService.swift` - [ ] **Step 1: Rewrite DeviceInfoService** Replace the entire file content with: ```swift import Foundation final class DeviceInfoService { private let protocolService: BleProtocolService init(protocolService: BleProtocolService) { self.protocolService = protocolService } // All commands are fire-and-forget: send the JSON, response comes back // via BleProtocolService delegate → DuooomiBleSDK handles matching. func getDeviceInfo() throws { let payload = CommandPayload(type: CommandType.getDeviceInfo.rawValue) try protocolService.sendJSON(type: .getDeviceInfo, payload: payload) } func getDeviceVersion() throws { let payload = CommandPayload(type: CommandType.getDeviceVersion.rawValue) try protocolService.sendJSON(type: .getDeviceVersion, payload: payload) } func bindDevice(userId: String) throws { let payload = BindPayload(type: CommandType.bindDevice.rawValue, userId: userId) try protocolService.sendJSON(type: .bindDevice, payload: payload) } func unbindDevice(userId: String) throws { let payload = UnbindPayload(type: CommandType.unbindDevice.rawValue, userId: userId) try protocolService.sendJSON(type: .unbindDevice, payload: payload) } func deleteFile(key: String) throws { let payload = FileKeyPayload(type: CommandType.deleteFile.rawValue, key: key) try protocolService.sendJSON(type: .deleteFile, payload: payload) } func prepareTransfer(key: String, size: Int) throws { let payload = PrepareTransferPayload(type: CommandType.prepareTransfer.rawValue, key: key, size: size) try protocolService.sendJSON(type: .prepareTransfer, payload: payload) } } // MARK: - Internal Payloads private struct CommandPayload: Encodable { let type: UInt8 } private struct BindPayload: Encodable { let type: UInt8 let userId: String } private struct UnbindPayload: Encodable { let type: UInt8 let userId: String } private struct FileKeyPayload: Encodable { let type: UInt8 let key: String } private struct PrepareTransferPayload: Encodable { let type: UInt8 let key: String let size: Int } ``` - [ ] **Step 2: Commit** ```bash git add Sources/DuooomiBleSDK/Services/DeviceInfoService.swift git commit -m "refactor: rewrite DeviceInfoService sync fire-and-forget (iOS 12.2)" ``` --- ## Task 6: Rewrite FileTransferService (async → callback) **Files:** - Modify: `Sources/DuooomiBleSDK/Services/FileTransferService.swift` - [ ] **Step 1: Rewrite FileTransferService** Replace the entire file content with: ```swift import Foundation final class FileTransferService { private let protocolService: BleProtocolService init(protocolService: BleProtocolService) { self.protocolService = protocolService } /// 传输文件到设备 func transferFile( fileUri: String, commandType: CommandType, onProgress: ((Double) -> Void)? = nil, completion: @escaping (Result) -> Void ) { loadFileData(from: fileUri) { [weak self] result in switch result { case .failure(let error): completion(.failure(error)) case .success(let data): BleLog.i("File loaded: \(data.count) bytes, sending as \(commandType)", "Transfer") self?.protocolService.send( type: commandType.rawValue, data: data, onProgress: onProgress, completion: completion ) } } } private func loadFileData(from uri: String, completion: @escaping (Result) -> Void) { if uri.hasPrefix("file://"), let url = URL(string: uri) { do { let data = try Data(contentsOf: url) completion(.success(data)) } catch { completion(.failure(DuooomiBleError.transferFailed("Failed to read local file: \(error.localizedDescription)"))) } return } guard let url = URL(string: uri) else { completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(uri)"))) return } let task = URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { completion(.failure(DuooomiBleError.transferFailed("Download failed: \(error.localizedDescription)"))) return } guard let data = data, !data.isEmpty else { completion(.failure(DuooomiBleError.transferFailed("Empty download response"))) return } completion(.success(data)) } task.resume() } } ``` - [ ] **Step 2: Commit** ```bash git add Sources/DuooomiBleSDK/Services/FileTransferService.swift git commit -m "refactor: rewrite FileTransferService with callbacks (iOS 12.2)" ``` --- ## Task 7: Rewrite AniConverter (async → callback) **Files:** - Modify: `Sources/DuooomiBleSDK/Services/AniConverter.swift` - [ ] **Step 1: Rewrite AniConverter** Replace the entire file content with: ```swift import Foundation struct AniConverterError: LocalizedError { let message: String var errorDescription: String? { message } } final class AniConverter { private let config: DuooomiBleConfig private var endpoint: URL { URL(string: "\(config.apiHost)/api/auth/loomart/file/convert-to-ani")! } private let session: URLSession = { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 120 cfg.timeoutIntervalForResource = 180 cfg.waitsForConnectivity = true return URLSession(configuration: cfg) }() init(config: DuooomiBleConfig) { self.config = config } /// 调用 ANI 转换接口,返回 ani 文件的 CDN URL。 func convert(fileUrl: String, completion: @escaping (Result) -> Void) { performConvert(fileUrl: fileUrl) { result in switch result { case .success: completion(result) case .failure(let error): // Retry once on connection lost if (error as NSError).code == NSURLErrorNetworkConnectionLost { self.performConvert(fileUrl: fileUrl, completion: completion) } else { completion(.failure(error)) } } } } private func performConvert(fileUrl: String, completion: @escaping (Result) -> Void) { var request = URLRequest(url: endpoint) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "content-type") request.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") request.timeoutInterval = 120 let body: [String: Any] = [ "videoUrl": fileUrl, "width": config.aniWidth, "height": config.aniHeight, "fps": config.aniFps, ] do { request.httpBody = try JSONSerialization.data(withJSONObject: body) } catch { completion(.failure(error)) return } session.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(error)) return } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { completion(.failure(AniConverterError(message: "HTTP \(http.statusCode)"))) return } guard let data = data, let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { completion(.failure(AniConverterError(message: "Invalid JSON response"))) return } guard (json["success"] as? Bool) == true, let outer = json["data"] as? [String: Any] else { completion(.failure(AniConverterError(message: "ANI API reported failure"))) return } if (outer["status"] as? Bool) != true { let msg = (outer["msg"] as? String) ?? "ANI conversion failed" completion(.failure(AniConverterError(message: msg))) return } guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else { completion(.failure(AniConverterError(message: "Missing ani url in response"))) return } completion(.success(aniUrl)) }.resume() } } ``` - [ ] **Step 2: Commit** ```bash git add Sources/DuooomiBleSDK/Services/AniConverter.swift git commit -m "refactor: rewrite AniConverter with callbacks (iOS 12.2)" ``` --- ## Task 8: Rewrite FirmwareService (async → callback) **Files:** - Modify: `Sources/DuooomiBleSDK/Services/FirmwareService.swift` - [ ] **Step 1: Rewrite FirmwareService** Replace the entire file content with: ```swift import Foundation /// 固件信息 public struct FirmwareInfo: Codable, Equatable { public let version: String public let fileUrl: String public let description: String? public let fileSize: String? public let fileMd5: String? public let identifier: String? public let status: String? } struct FirmwareResponse: Codable { let success: Bool let data: FirmwareInfo? } /// 固件服务内部实现 final class FirmwareService { private let config: DuooomiBleConfig private let latestPath = "api/auth/loomart/firmware/latest-published" init(config: DuooomiBleConfig) { self.config = config } func fetchLatest( identifier: String? = nil, status: String? = nil, completion: @escaping (Result) -> Void ) { let id = (identifier ?? config.firmwareIdentifier).trimmingCharacters(in: .whitespacesAndNewlines) let st = status ?? config.firmwareStatus guard !id.isEmpty else { completion(.failure(DuooomiBleError.transferFailed("Invalid firmware identifier"))) return } guard var comps = URLComponents(string: "\(config.apiHost)/\(latestPath)") else { completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) return } comps.queryItems = [ URLQueryItem(name: "identifier", value: id), URLQueryItem(name: "status", value: st) ] guard let url = comps.url else { completion(.failure(DuooomiBleError.transferFailed("Invalid firmware URL"))) return } var req = URLRequest(url: url) req.httpMethod = "GET" req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") req.setValue("application/json", forHTTPHeaderField: "accept") URLSession.shared.dataTask(with: req) { data, resp, error in if let error = error { completion(.failure(error)) return } guard let http = resp as? HTTPURLResponse else { completion(.failure(DuooomiBleError.transferFailed("Invalid response"))) return } guard (200...299).contains(http.statusCode) else { completion(.failure(DuooomiBleError.transferFailed("HTTP \(http.statusCode)"))) return } guard let data = data else { completion(.failure(DuooomiBleError.transferFailed("Empty response"))) return } do { let decoded = try JSONDecoder().decode(FirmwareResponse.self, from: data) completion(.success(decoded.data)) } catch { completion(.failure(error)) } }.resume() } } ``` - [ ] **Step 2: Commit** ```bash git add Sources/DuooomiBleSDK/Services/FirmwareService.swift git commit -m "refactor: rewrite FirmwareService with callbacks (iOS 12.2)" ``` --- ## Task 9: Rewrite DuooomiBleSDK Main Class **Files:** - Modify: `Sources/DuooomiBleSDK/DuooomiBleSDK.swift` This is the largest change. The entire class is rewritten: - Remove `@MainActor`, `ObservableObject`, `@Published`, `import Combine` - All `async throws` → completion handlers - `withThrowingTaskGroup` request-response → `DispatchWorkItem` + timeout - `Task` → `DispatchQueue` - Add delegate-based state notification - [ ] **Step 1: Rewrite DuooomiBleSDK.swift** Replace the entire file content with: ```swift import CoreBluetooth import Foundation /// Duooomi BLE SDK - 纯原生 iOS 蓝牙 SDK /// /// 提供设备扫描、连接、命令交互、文件传输等原子蓝牙操作。 /// 不做业务编排,调用方自行串联多步流程。 public final class DuooomiBleSDK: NSObject { // MARK: - Observable State public weak var delegate: DuooomiBleSDKDelegate? public private(set) var btState: ConnectionState = .idle { didSet { guard btState != oldValue else { return } notifyMain { self.delegate?.sdk(self, didChangeState: self.btState) } } } public private(set) var connectedDevice: DiscoveredDevice? = nil { didSet { notifyMain { self.delegate?.sdk(self, didConnectDevice: self.connectedDevice) } } } public private(set) var deviceInfo: DeviceInfo? = nil { didSet { notifyMain { self.delegate?.sdk(self, didUpdateDeviceInfo: self.deviceInfo) } } } public private(set) var version: String = "" { didSet { notifyMain { self.delegate?.sdk(self, didUpdateVersion: self.version) } } } public private(set) var isActivated: Bool = false { didSet { guard isActivated != oldValue else { return } notifyMain { self.delegate?.sdk(self, didChangeActivation: self.isActivated) } } } public private(set) var transferProgress: Int = 0 { didSet { guard transferProgress != oldValue else { return } notifyMain { self.delegate?.sdk(self, didUpdateProgress: self.transferProgress) } } } public private(set) var error: String? = nil { didSet { notifyMain { self.delegate?.sdk(self, didEncounterError: self.error) } } } public private(set) var discoveredDevices: [DiscoveredDevice] = [] { didSet { notifyMain { self.delegate?.sdk(self, didUpdateDevices: self.discoveredDevices) } } } // MARK: - Configuration public let config: DuooomiBleConfig // MARK: - Internal Services private let bleClient: BleClient private let protocolService: BleProtocolService private let deviceInfoService: DeviceInfoService private let fileTransferService: FileTransferService private let aniConverter: AniConverter private let firmwareService: FirmwareService // MARK: - Request-Response private var pendingCallbacks: [String: (Result) -> Void] = [:] private var pendingTimeouts: [String: DispatchWorkItem] = [:] private let callbackQueue = DispatchQueue(label: "com.duooomi.ble.callback") // MARK: - Scan Batching private var allDiscoveredDevices: [DiscoveredDevice] = [] private var pendingDevices: [DiscoveredDevice] = [] private var flushWorkItem: DispatchWorkItem? // MARK: - Init public init(config: DuooomiBleConfig) { self.config = config bleClient = BleClient() protocolService = BleProtocolService(client: bleClient) deviceInfoService = DeviceInfoService(protocolService: protocolService) fileTransferService = FileTransferService(protocolService: protocolService) aniConverter = AniConverter(config: config) firmwareService = FirmwareService(config: config) super.init() protocolService.delegate = self BleLog.i("SDK initialized (apiHost=\(config.apiHost), firmware=\(config.firmwareIdentifier)/\(config.firmwareStatus))", "SDK") setupDisconnectHandler() } private func setupDisconnectHandler() { bleClient.onDisconnected = { [weak self] in guard let self = self else { return } BleLog.w("Peripheral disconnected; resetting state", "SDK") self.connectedDevice = nil self.deviceInfo = nil self.version = "" self.isActivated = false self.btState = .disconnected self.protocolService.stopListening() self.cancelAllPending(error: DuooomiBleError.notConnected) } } // MARK: - Scanning /// 开始扫描设备。 public func scan() { stopScan() btState = .scanning discoveredDevices = [] allDiscoveredDevices = [] pendingDevices = [] BleLog.i("Start scanning...", "Scan") bleClient.delegate = protocolService // Also listen for scan results let originalDelegate = bleClient.delegate bleClient.delegate = self.scanProxy(original: originalDelegate) bleClient.scan() } /// 停止扫描设备。 public func stopScan() { bleClient.stopScan() flushDevices() if connectedDevice != nil { btState = .connected } else if btState == .scanning { btState = .idle } BleLog.i("Stop scanning (state=\(btState.rawValue))", "Scan") } // MARK: - Connection /// 连接到指定设备。 public func connect(deviceId: String, completion: @escaping (Result) -> Void) { stopScan() btState = .connecting BleLog.i("Connecting to \(deviceId)...", "Connect") bleClient.connect(deviceId: deviceId) { [weak self] result in guard let self = self else { return } switch result { case .success(let peripheral): self.protocolService.startListening() let device = DiscoveredDevice( id: peripheral.identifier.uuidString, name: peripheral.name, rssi: 0 ) self.connectedDevice = device self.btState = .connected BleLog.i("Connected: \(device.id)", "Connect") completion(.success(device)) case .failure(let error): self.btState = .idle self.error = error.localizedDescription BleLog.e("Connect failed: \(error.localizedDescription)", "Connect") completion(.failure(error)) } } } /// 断开当前连接。 public func disconnect(completion: @escaping (Result) -> Void) { btState = .disconnecting BleLog.i("Disconnect requested", "Connect") protocolService.stopListening() cancelAllPending(error: DuooomiBleError.notConnected) bleClient.disconnect { [weak self] result in guard let self = self else { return } if case .failure(let error) = result { self.error = error.localizedDescription BleLog.e("Disconnect error: \(error.localizedDescription)", "Connect") } self.connectedDevice = nil self.deviceInfo = nil self.version = "" self.isActivated = false self.btState = .disconnected BleLog.i("Disconnected", "Connect") completion(result) } } /// 返回系统已连接的设备 public func getConnectedDevices() -> [DiscoveredDevice] { let list = bleClient.retrieveConnectedDevices() BleLog.d("System-connected devices: \(list.count)", "Connect") return list } // MARK: - Device Commands /// 读取设备信息 public func getDeviceInfo(completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending getDeviceInfo", "Command") sendAndWait(commandType: .getDeviceInfo) { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let info = try self.decodeResponse(DeviceInfo.self, from: data) BleLog.i("DeviceInfo received: name=\(info.name), brand=\(info.brand)", "Command") self.deviceInfo = info completion(.success(info)) } catch { completion(.failure(error)) } } } send: { try self.deviceInfoService.getDeviceInfo() } } /// 读取设备固件版本 public func getVersion(completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending getDeviceVersion", "Command") sendAndWait(commandType: .getDeviceVersion) { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let info = try self.decodeResponse(VersionInfo.self, from: data) self.version = info.version BleLog.i("Version received: \(info.version) (type=\(info.type))", "Command") completion(.success(info)) } catch { completion(.failure(error)) } } } send: { try self.deviceInfoService.getDeviceVersion() } } /// 绑定设备到用户 public func bind(userId: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending bind (userId=\(userId))", "Command") sendAndWait(commandType: .bindDevice) { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(BindingResponse.self, from: data) self.isActivated = resp.success == 1 if resp.success != 1 { BleLog.w("Bind failed: device already bound", "Command") completion(.failure(DuooomiBleError.bindingFailed("Device already bound to another user"))) } else { BleLog.i("Bind success: sn=\(resp.sn)", "Command") completion(.success(resp)) } } catch { completion(.failure(error)) } } } send: { try self.deviceInfoService.bindDevice(userId: userId) } } /// 解除设备绑定 public func unbind(userId: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending unbind (userId=\(userId))", "Command") sendAndWait(commandType: .unbindDevice) { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(UnbindResponse.self, from: data) if resp.success == 1 { self.isActivated = false BleLog.i("Unbind success", "Command") completion(.success(resp)) } else { BleLog.w("Unbind failed", "Command") completion(.failure(DuooomiBleError.unbindFailed)) } } catch { completion(.failure(error)) } } } send: { try self.deviceInfoService.unbindDevice(userId: userId) } } /// 删除设备文件 public func deleteFile(key: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } BleLog.d("Sending deleteFile (key=\(key))", "Command") sendAndWait(commandType: .deleteFile) { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(DeleteFileResponse.self, from: data) if resp.success != 0 { BleLog.w("Delete failed with status=\(resp.success)", "Command") completion(.failure(DuooomiBleError.deleteFileFailed(status: resp.success))) } else { BleLog.i("Delete success (key=\(key))", "Command") completion(.success(resp)) } } catch { completion(.failure(error)) } } } send: { try self.deviceInfoService.deleteFile(key: key) } } /// 传输前准备校验 public func prepareTransfer(key: String, size: Int, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } let opId = "\(CommandType.prepareTransfer.rawValue)_\(key)" BleLog.d("Sending prepareTransfer (key=\(key), size=\(size))", "Transfer") sendAndWait(opId: opId, commandType: .prepareTransfer) { [weak self] result in guard let self = self else { return } switch result { case .failure(let error): completion(.failure(error)) case .success(let data): do { let resp = try self.decodeResponse(PrepareTransferResponse.self, from: data) if resp.status != "ready" { BleLog.w("PrepareTransfer not ready: status=\(resp.status)", "Transfer") completion(.failure(DuooomiBleError.prepareTransferFailed(status: resp.status))) } else { BleLog.i("PrepareTransfer ready (key=\(key))", "Transfer") completion(.success(resp)) } } catch { completion(.failure(error)) } } } send: { try self.deviceInfoService.prepareTransfer(key: key, size: size) } } // MARK: - File Transfer /// 传输文件到设备 public func transferFile( fileUri: String, commandType: CommandType = .transferAniVideo, completion: @escaping (Result) -> Void ) { guard connectedDevice != nil else { completion(.failure(DuooomiBleError.notConnected)) return } transferProgress = 0 BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer") fileTransferService.transferFile( fileUri: fileUri, commandType: commandType, onProgress: { [weak self] progress in DispatchQueue.main.async { let p = Int(progress * 100) self?.transferProgress = p if p % 10 == 0 { BleLog.d("Progress: \(p)%", "Transfer") } } }, completion: { [weak self] result in if case .success = result { self?.transferProgress = 100 BleLog.i("Transfer completed", "Transfer") } completion(result) } ) } // MARK: - High-Level APIs /// 传输媒体文件到设备(一步完成) public func transferMedia(fileUrl: String, completion: @escaping (Result) -> Void) { guard connectedDevice != nil else { completion(.failure(DuooomiBleError.notConnected)) return } let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { completion(.failure(DuooomiBleError.transferFailed("Empty file URL"))) return } let isAni = url.lowercased().hasSuffix(".ani") if isAni { BleLog.i("Direct ANI transfer: \(url)", "Transfer") transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion) } else { BleLog.i("Converting to ANI: \(url)", "Transfer") aniConverter.convert(fileUrl: url) { [weak self] result in switch result { case .failure(let error): completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)"))) case .success(let aniUrl): BleLog.i("ANI ready: \(aniUrl)", "Transfer") self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion) } } } } private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result) -> Void) { getRemoteFileSize(url: aniUrl) { [weak self] result in switch result { case .failure(let error): completion(.failure(error)) case .success(let aniSize): BleLog.d("ANI size: \(aniSize) bytes", "Transfer") self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in switch result { case .failure(let error): completion(.failure(error)) case .success: self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion) } } } } } /// 获取最新固件信息 public func fetchLatestFirmware( identifier: String? = nil, status: String? = nil, completion: @escaping (Result) -> Void ) { firmwareService.fetchLatest(identifier: identifier, status: status, completion: completion) } /// 比较设备版本和服务端版本 public func hasNewerFirmware(deviceVersion: String?, serverVersion: String?) -> Bool { guard let device = deviceVersion, let server = serverVersion, device.count >= 10, server.count >= 10 else { return false } let deviceTs = device.suffix(10) let serverTs = server.suffix(10) guard let d = Int(deviceTs), let s = Int(serverTs) else { return false } return s > d } /// OTA 固件升级 public func upgradeFirmware(fileUrl: String, completion: @escaping (Result) -> Void) { guard connectedDevice != nil else { completion(.failure(DuooomiBleError.notConnected)) return } BleLog.i("OTA upgrade start: \(fileUrl)", "Firmware") transferFile(fileUri: fileUrl, commandType: .otaPackage) { result in if case .success = result { BleLog.i("OTA upgrade completed", "Firmware") } completion(result) } } // MARK: - Internal Helpers private func getRemoteFileSize(url: String, completion: @escaping (Result) -> Void) { guard let fileUrl = URL(string: url) else { completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)"))) return } var request = URLRequest(url: fileUrl) request.httpMethod = "HEAD" URLSession.shared.dataTask(with: request) { [weak self] _, response, error in if let error = error { completion(.failure(error)) return } if let http = response as? HTTPURLResponse { // Try Content-Length header if let lengthStr = http.value(forHTTPHeaderField: "Content-Length"), let length = Int(lengthStr), length > 0 { completion(.success(length)) return } if http.expectedContentLength > 0 { completion(.success(Int(http.expectedContentLength))) return } } // Fallback: Range request self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion) }.resume() } private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result) -> Void) { var rangeReq = URLRequest(url: fileUrl) rangeReq.httpMethod = "GET" rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range") URLSession.shared.dataTask(with: rangeReq) { _, response, error in if let error = error { completion(.failure(error)) return } if let http = response as? HTTPURLResponse, let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"), let totalStr = rangeHeader.split(separator: "/").last, let total = Int(totalStr), total > 0 { completion(.success(total)) return } completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)"))) }.resume() } // MARK: - Request-Response Pattern private func sendAndWait( commandType: CommandType, timeout: TimeInterval = 10, completion: @escaping (Result) -> Void, send: @escaping () throws -> Void ) { let opId = "\(commandType.rawValue)" sendAndWait(opId: opId, commandType: commandType, timeout: timeout, completion: completion, send: send) } private func sendAndWait( opId: String, commandType: CommandType, timeout: TimeInterval = 10, completion: @escaping (Result) -> Void, send: @escaping () throws -> Void ) { BleLog.d("Register opId=\(opId), cmd=\(commandType)", "RPC") // Register callback BEFORE sending callbackQueue.sync { self.pendingCallbacks[opId] = { result in DispatchQueue.main.async { completion(result) } } } // Setup timeout let timeoutWork = DispatchWorkItem { [weak self] in guard let self = self else { return } self.callbackQueue.sync { if let cb = self.pendingCallbacks.removeValue(forKey: opId) { self.pendingTimeouts.removeValue(forKey: opId) cb(.failure(DuooomiBleError.timeout(command: commandType))) } } } callbackQueue.sync { pendingTimeouts[opId] = timeoutWork } DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork) // Send command do { try send() BleLog.d("Command sent for opId=\(opId)", "RPC") } catch { // Clean up on send failure callbackQueue.sync { self.pendingCallbacks.removeValue(forKey: opId) self.pendingTimeouts.removeValue(forKey: opId)?.cancel() } BleLog.e("Send failed for opId=\(opId): \(error.localizedDescription)", "RPC") DispatchQueue.main.async { completion(.failure(error)) } } } // MARK: - Helpers private func ensureConnected(completion: @escaping (Result) -> Void) -> Bool { guard connectedDevice != nil else { BleLog.w("Operation requires connection", "SDK") completion(.failure(DuooomiBleError.notConnected)) return false } return true } private func decodeResponse(_ type: T.Type, from data: Data) throws -> T { let cleaned = data.filter { $0 != 0 } do { return try JSONDecoder().decode(type, from: cleaned) } catch { let raw = String(data: cleaned, encoding: .utf8) ?? "" BleLog.e("JSON decode \(T.self) failed: \(error.localizedDescription)\nRaw: \(raw)", "Decode") throw error } } private func cancelAllPending(error: Error) { callbackQueue.sync { let callbacks = self.pendingCallbacks self.pendingCallbacks.removeAll() for (_, timeout) in self.pendingTimeouts { timeout.cancel() } self.pendingTimeouts.removeAll() for (_, cb) in callbacks { cb(.failure(error)) } } } private func notifyMain(_ block: @escaping () -> Void) { if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) } } // MARK: - Scan Batching (500ms throttle) private func queueDevice(_ device: DiscoveredDevice) { guard !allDiscoveredDevices.contains(where: { $0.id == device.id }) else { return } allDiscoveredDevices.append(device) pendingDevices.append(device) flushWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.flushDevices() } flushWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) } private func flushDevices() { guard !pendingDevices.isEmpty else { return } pendingDevices = [] discoveredDevices = allDiscoveredDevices BleLog.d("Discovered devices flushed: total=\(discoveredDevices.count)", "Scan") } } // MARK: - BleProtocolServiceDelegate (incoming messages) extension DuooomiBleSDK: BleProtocolServiceDelegate { func protocolService(_ service: BleProtocolService, didReceiveMessage commandType: UInt8, data: Data) { BleLog.d("Incoming message: type=\(commandType), size=\(data.count)", "RPC") let opId = "\(commandType)" // Check for prepareTransfer with key-based opId if commandType == CommandType.prepareTransfer.rawValue { if let resp = try? decodeResponse(PrepareTransferResponse.self, from: data) { let keyedOpId = "\(commandType)_\(resp.key)" var cb: ((Result) -> Void)? callbackQueue.sync { cb = self.pendingCallbacks.removeValue(forKey: keyedOpId) self.pendingTimeouts.removeValue(forKey: keyedOpId)?.cancel() } if let cb = cb { BleLog.d("Resuming keyed opId=\(keyedOpId)", "RPC") cb(.success(data)) return } } } var cb: ((Result) -> Void)? callbackQueue.sync { cb = self.pendingCallbacks.removeValue(forKey: opId) self.pendingTimeouts.removeValue(forKey: opId)?.cancel() } if let cb = cb { BleLog.d("Resuming opId=\(opId)", "RPC") cb(.success(data)) } else { BleLog.w("No pending callback for opId=\(opId)", "RPC") } } } // MARK: - Scan Proxy (forward scan results while keeping protocol delegate) private class ScanBleClientDelegate: BleClientDelegate { private weak var sdk: DuooomiBleSDK? private let original: BleClientDelegate? init(sdk: DuooomiBleSDK, original: BleClientDelegate?) { self.sdk = sdk self.original = original } func bleClient(_ client: BleClient, didDiscoverDevice device: DiscoveredDevice) { BleLog.d("Discovered: id=\(device.id), name=\(device.name ?? "-"), rssi=\(device.rssi)", "Scan") sdk?.queueDevice(device) } func bleClient(_ client: BleClient, didReceiveData data: Data) { original?.bleClient(client, didReceiveData: data) } } private extension DuooomiBleSDK { func scanProxy(original: BleClientDelegate?) -> BleClientDelegate { return ScanBleClientDelegate(sdk: self, original: original) } } ``` - [ ] **Step 2: Build to check compile errors** Run: `xcodebuild build -workspace demo.xcworkspace -scheme DuooomiBleSDK -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|BUILD"` Expected: BUILD SUCCEEDED (or errors only in demo app which still uses old API) - [ ] **Step 3: Commit** ```bash git add Sources/DuooomiBleSDK/DuooomiBleSDK.swift git commit -m "refactor: rewrite DuooomiBleSDK with delegate+callbacks (iOS 12.2)" ``` --- ## Task 10: Update Build Configuration **Files:** - Modify: `Package.swift` - Modify: `project.yml` - Modify: `DuooomiBleSDK.podspec` - [ ] **Step 1: Package.swift — Change iOS target** ```swift // Before: .iOS(.v16) // After: .iOS(.v12) ``` Note: SPM's `.iOS` enum may not have `.v12`. If so, use: ```swift .iOS(.init("12.2")) ``` - [ ] **Step 2: project.yml — Change deployment target** ```yaml # Before: deploymentTarget: iOS: "16.0" # After: deploymentTarget: iOS: "12.2" ``` - [ ] **Step 3: DuooomiBleSDK.podspec — Change deployment target** ```ruby # Before: s.ios.deployment_target = '12.2' # After (already correct if changed): s.ios.deployment_target = '12.2' ``` - [ ] **Step 4: Commit** ```bash git add Package.swift project.yml DuooomiBleSDK.podspec git commit -m "chore: set deployment target to iOS 12.2" ``` --- ## Task 11: Update Demo App **Files:** - Modify: `demo/Sources/DemoApp.swift` (and any other demo source files) The demo app needs to be updated to use the new callback-based API instead of async/await. Read all demo source files and update every SDK call. - [ ] **Step 1: Read all demo source files** Check `demo/Sources/` for all Swift files and update them to use completion handler API. Key changes: - Remove `await` / `async` / `Task { }` wrappers - Use completion-handler versions of all SDK methods - Implement `DuooomiBleSDKDelegate` instead of observing `@Published` - Replace `@StateObject` / `@ObservedObject` with manual state management if using SwiftUI, or use UIKit delegate pattern - [ ] **Step 2: Build and verify** Run: `xcodebuild build -workspace demo.xcworkspace -scheme demo -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|BUILD"` Expected: BUILD SUCCEEDED - [ ] **Step 3: Commit** ```bash git add demo/ git commit -m "refactor: update demo app for callback-based SDK API" ``` --- ## Task 12: Update README **Files:** - Modify: `README.md` - [ ] **Step 1: Update README with new API style** Key sections to update: - Minimum iOS version: `12.2` - Remove mention of `@Published` / `ObservableObject` - Add delegate setup example - Change all API examples from `async/await` to completion handlers - Update tech specs section - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "docs: update README for iOS 12.2 callback-based API" ``` --- ## Task 13: Full Build Verification - [ ] **Step 1: Clean build SDK** Run: `xcodebuild clean build -workspace demo.xcworkspace -scheme DuooomiBleSDK -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5` Expected: BUILD SUCCEEDED - [ ] **Step 2: Clean build demo** Run: `xcodebuild clean build -workspace demo.xcworkspace -scheme demo -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5` Expected: BUILD SUCCEEDED - [ ] **Step 3: Verify no iOS 13+ API usage remains** Run: `grep -rn "async \|await \|AsyncStream\|@Published\|ObservableObject\|@MainActor\|Sendable\|CheckedContinuation\|withThrowingTaskGroup\|Task {" Sources/DuooomiBleSDK/` Expected: No matches - [ ] **Step 4: Final commit if any fixes needed** ```bash git add -A git commit -m "fix: resolve remaining iOS 12.2 compat issues" ```