Files
duooomi-ios-sdk/docs/superpowers/plans/2026-04-16-ios12-compat-rewrite.md
km2023 1a7bbdc625 feat: 重写 SDK 支持 iOS 12.2,移除 async/await 和 Combine
- 所有公开 API 改为 completion handler (Result<T, Error>)
- 状态通知改为 DuooomiBleSDKDelegate 协议
- 移除 Sendable、@MainActor、AsyncStream、CheckedContinuation
- 网络请求改为 URLSession.dataTask 回调
- BLE 通信改为 delegate + 闭包模式
- deployment target 降至 iOS 12.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:59:39 +08:00

1963 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T, Error>`), 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<CBPeripheral, Error>) -> Void)?
private var disconnectCompletion: ((Result<Void, Error>) -> 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<CBPeripheral, Error>) -> 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, Error>) -> 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, Error>) -> 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<T: Encodable>(type: CommandType, payload: T, completion: @escaping (Result<Void, Error>) -> 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<T: Encodable>(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, Error>) -> 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<Data, Error>) -> 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<String, Error>) -> 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<String, Error>) -> 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<FirmwareInfo?, Error>) -> 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<Data, Error>) -> 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<DiscoveredDevice, Error>) -> 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, Error>) -> 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<DeviceInfo, Error>) -> 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<VersionInfo, Error>) -> 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<BindingResponse, Error>) -> 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<UnbindResponse, Error>) -> 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<DeleteFileResponse, Error>) -> 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<PrepareTransferResponse, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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<FirmwareInfo?, Error>) -> 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, Error>) -> 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<Int, Error>) -> 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<Int, Error>) -> 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<Data, Error>) -> 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<Data, Error>) -> 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<T>(completion: @escaping (Result<T, Error>) -> Void) -> Bool {
guard connectedDevice != nil else {
BleLog.w("Operation requires connection", "SDK")
completion(.failure(DuooomiBleError.notConnected))
return false
}
return true
}
private func decodeResponse<T: Decodable>(_ 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) ?? "<non-utf8, \(cleaned.count) bytes>"
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<Data, Error>) -> 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<Data, Error>) -> 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"
```