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>
This commit is contained in:
@@ -21,18 +21,9 @@ private enum CDNHelper {
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
//
|
||||
// Demo 演示 SDK 全部功能:
|
||||
// 1. Scan & Connect — sdk.scan() / sdk.connect(deviceId:) / sdk.disconnect()
|
||||
// 2. Device — sdk.getDeviceInfo() / sdk.getVersion() / sdk.bind(userId:) / sdk.unbind(userId:)
|
||||
// 3. Transfer — sdk.transferMedia(fileUrl:) 一步传输(内部自动 ANI 转换 + prepare + transfer)
|
||||
// 4. Firmware — sdk.fetchLatestFirmware() 查询 + sdk.transferFile(fileUri:commandType:.otaPackage) OTA
|
||||
// 5. Device Files — sdk.deleteFile(key:) 删除设备文件
|
||||
//
|
||||
// 可观察状态:btState / connectedDevice / deviceInfo / version / transferProgress / error
|
||||
|
||||
struct WrapperTestView: View {
|
||||
@EnvironmentObject var sdk: DuooomiBleSDK
|
||||
@ObservedObject var viewModel: DemoViewModel
|
||||
|
||||
@State private var userId = "test-user-001"
|
||||
@State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4"
|
||||
@@ -68,9 +59,9 @@ struct WrapperTestView: View {
|
||||
.navigationTitle("SDK Demo")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onAppear {
|
||||
CDNHelper.cdnHost = sdk.config.cdnHost
|
||||
firmwareBrand = sdk.config.firmwareIdentifier
|
||||
firmwareStatus = sdk.config.firmwareStatus
|
||||
CDNHelper.cdnHost = viewModel.sdk.config.cdnHost
|
||||
firmwareBrand = viewModel.sdk.config.firmwareIdentifier
|
||||
firmwareStatus = viewModel.sdk.config.firmwareStatus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,15 +69,15 @@ struct WrapperTestView: View {
|
||||
|
||||
private var statusSection: some View {
|
||||
Section("Status") {
|
||||
LabeledContent("BLE", value: sdk.btState.rawValue)
|
||||
LabeledContent("Device", value: sdk.connectedDevice?.name ?? "—")
|
||||
LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
||||
if sdk.transferProgress > 0 {
|
||||
ProgressView(value: Double(sdk.transferProgress), total: 100) {
|
||||
Text("\(sdk.transferProgress)%").font(.caption)
|
||||
LabeledContent("BLE", value: viewModel.btState.rawValue)
|
||||
LabeledContent("Device", value: viewModel.connectedDevice?.name ?? "—")
|
||||
LabeledContent("Version", value: viewModel.version.isEmpty ? "—" : viewModel.version)
|
||||
if viewModel.transferProgress > 0 {
|
||||
ProgressView(value: Double(viewModel.transferProgress), total: 100) {
|
||||
Text("\(viewModel.transferProgress)%").font(.caption)
|
||||
}
|
||||
}
|
||||
if let err = sdk.error {
|
||||
if let err = viewModel.error {
|
||||
Text(err).foregroundStyle(.red).font(.caption)
|
||||
}
|
||||
}
|
||||
@@ -96,19 +87,19 @@ struct WrapperTestView: View {
|
||||
Section("Scan & Connect") {
|
||||
HStack {
|
||||
Button("Scan") {
|
||||
sdk.scan()
|
||||
viewModel.sdk.scan()
|
||||
log("→ scan()")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Stop") {
|
||||
sdk.stopScan()
|
||||
viewModel.sdk.stopScan()
|
||||
log("→ stopScan()")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
ForEach(sdk.discoveredDevices) { device in
|
||||
ForEach(viewModel.discoveredDevices) { device in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(device.name ?? "Unknown")
|
||||
@@ -117,9 +108,9 @@ struct WrapperTestView: View {
|
||||
Spacer()
|
||||
Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
|
||||
|
||||
if sdk.connectedDevice?.id == device.id {
|
||||
if viewModel.connectedDevice?.id == device.id {
|
||||
Button("Disconnect") {
|
||||
Task { await runDisconnect() }
|
||||
runDisconnect()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -128,7 +119,7 @@ struct WrapperTestView: View {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Connect") {
|
||||
Task { await runConnect(deviceId: device.id) }
|
||||
runConnect(deviceId: device.id)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -141,23 +132,23 @@ struct WrapperTestView: View {
|
||||
|
||||
private var deviceSection: some View {
|
||||
Section("Device") {
|
||||
LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—")
|
||||
LabeledContent("Size", value: sdk.deviceInfo?.size ?? "—")
|
||||
LabeledContent("Power", value: sdk.deviceInfo.map { "\($0.powerlevel)%" } ?? "—")
|
||||
LabeledContent("Storage", value: sdk.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—")
|
||||
LabeledContent("Brand", value: viewModel.deviceInfo?.brand ?? "—")
|
||||
LabeledContent("Size", value: viewModel.deviceInfo?.size ?? "—")
|
||||
LabeledContent("Power", value: viewModel.deviceInfo.map { "\($0.powerlevel)%" } ?? "—")
|
||||
LabeledContent("Storage", value: viewModel.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—")
|
||||
|
||||
HStack {
|
||||
Button("getDeviceInfo") { Task { await runGetDeviceInfo() } }
|
||||
Button("getVersion") { Task { await runGetVersion() } }
|
||||
Button("getDeviceInfo") { runGetDeviceInfo() }
|
||||
Button("getVersion") { runGetVersion() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
HStack {
|
||||
TextField("userId", text: $userId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("bind") { Task { await runBind() } }
|
||||
Button("bind") { runBind() }
|
||||
.buttonStyle(.bordered)
|
||||
Button("unbind") { Task { await runUnbind() } }
|
||||
Button("unbind") { runUnbind() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
@@ -171,10 +162,10 @@ struct WrapperTestView: View {
|
||||
.disableAutocorrection(true)
|
||||
|
||||
Button("Transfer") {
|
||||
Task { await runTransfer() }
|
||||
runTransfer()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(fileUrl.isEmpty || sdk.connectedDevice == nil || isBusy)
|
||||
.disabled(fileUrl.isEmpty || viewModel.connectedDevice == nil || isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +173,7 @@ struct WrapperTestView: View {
|
||||
|
||||
private var firmwareSection: some View {
|
||||
Section("Firmware Update") {
|
||||
LabeledContent("Current Version", value: sdk.version.isEmpty ? "—" : sdk.version)
|
||||
LabeledContent("Current Version", value: viewModel.version.isEmpty ? "—" : viewModel.version)
|
||||
|
||||
HStack {
|
||||
Text("Identifier")
|
||||
@@ -200,7 +191,7 @@ struct WrapperTestView: View {
|
||||
if let info = firmwareInfo {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
LabeledContent("Latest", value: info.version)
|
||||
LabeledContent("Has Update", value: sdk.hasNewerFirmware(deviceVersion: sdk.version, serverVersion: info.version) ? "Yes" : "No")
|
||||
LabeledContent("Has Update", value: viewModel.sdk.hasNewerFirmware(deviceVersion: viewModel.version, serverVersion: info.version) ? "Yes" : "No")
|
||||
if let size = info.fileSize {
|
||||
let bytes = Int(size) ?? 0
|
||||
LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))
|
||||
@@ -228,16 +219,16 @@ struct WrapperTestView: View {
|
||||
|
||||
HStack {
|
||||
Button("Get Update Info") {
|
||||
Task { await runFetchFirmwareInfo() }
|
||||
runFetchFirmwareInfo()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(firmwareLoading || firmwareBrand.isEmpty)
|
||||
|
||||
Button("Upgrade") {
|
||||
Task { await runUpgradeFirmware() }
|
||||
runUpgradeFirmware()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy || firmwareLoading)
|
||||
.disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +269,7 @@ struct WrapperTestView: View {
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await runDeleteFile(rawKey) }
|
||||
runDeleteFile(rawKey)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
@@ -306,117 +297,139 @@ struct WrapperTestView: View {
|
||||
if logs.count > 200 { logs.removeLast() }
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
// 每个 run* 方法对应一个 SDK API 调用,展示输入/输出和错误处理
|
||||
// MARK: - Actions (callback-based)
|
||||
|
||||
/// sdk.connect(deviceId:) → DiscoveredDevice
|
||||
private func runConnect(deviceId: String) async {
|
||||
private func runConnect(deviceId: String) {
|
||||
connectingDeviceId = deviceId
|
||||
defer { connectingDeviceId = nil }
|
||||
log("→ connect(\(deviceId))")
|
||||
do {
|
||||
let device = try await sdk.connect(deviceId: deviceId)
|
||||
log("connect ✓ \(device.name ?? device.id)", level: .success)
|
||||
} catch {
|
||||
log("connect ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.connect(deviceId: deviceId) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.connectingDeviceId = nil
|
||||
switch result {
|
||||
case .success(let device):
|
||||
self.log("connect ✓ \(device.name ?? device.id)", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("connect ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.disconnect()
|
||||
private func runDisconnect() async {
|
||||
private func runDisconnect() {
|
||||
log("→ disconnect")
|
||||
do {
|
||||
try await sdk.disconnect()
|
||||
deviceFiles = []
|
||||
log("disconnect ✓", level: .success)
|
||||
} catch {
|
||||
log("disconnect ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.disconnect { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success:
|
||||
self.deviceFiles = []
|
||||
self.log("disconnect ✓", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("disconnect ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.getDeviceInfo() → DeviceInfo
|
||||
private func runGetDeviceInfo() async {
|
||||
private func runGetDeviceInfo() {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ getDeviceInfo")
|
||||
do {
|
||||
let info = try await sdk.getDeviceInfo()
|
||||
log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success)
|
||||
} catch {
|
||||
log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.getDeviceInfo { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success(let info):
|
||||
self.log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.getVersion() → VersionInfo
|
||||
private func runGetVersion() async {
|
||||
private func runGetVersion() {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ getVersion")
|
||||
do {
|
||||
let info = try await sdk.getVersion()
|
||||
log("getVersion ✓ \(info.version)", level: .success)
|
||||
} catch {
|
||||
log("getVersion ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.getVersion { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success(let info):
|
||||
self.log("getVersion ✓ \(info.version)", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("getVersion ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.bind(userId:) → BindingResponse (sn + contents)。绑定后才能查看设备文件
|
||||
private func runBind() async {
|
||||
private func runBind() {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ bind(\(userId))")
|
||||
do {
|
||||
let result = try await sdk.bind(userId: userId)
|
||||
log("bind ✓ sn=\(result.sn) contents=\(result.contents.count)", level: .success)
|
||||
deviceFiles = result.contents
|
||||
} catch {
|
||||
log("bind ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.bind(userId: userId) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
self.log("bind ✓ sn=\(resp.sn) contents=\(resp.contents.count)", level: .success)
|
||||
self.deviceFiles = resp.contents
|
||||
case .failure(let error):
|
||||
self.log("bind ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.unbind(userId:) → UnbindResponse。解绑后该账号的设备文件将被删除
|
||||
private func runUnbind() async {
|
||||
private func runUnbind() {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ unbind(\(userId))")
|
||||
do {
|
||||
_ = try await sdk.unbind(userId: userId)
|
||||
deviceFiles = []
|
||||
log("unbind ✓", level: .success)
|
||||
} catch {
|
||||
log("unbind ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.unbind(userId: userId) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success:
|
||||
self.deviceFiles = []
|
||||
self.log("unbind ✓", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("unbind ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.transferMedia(fileUrl:) — 一步完成:自动转换 ANI + prepare + transfer
|
||||
private func runTransfer() async {
|
||||
private func runTransfer() {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ transferMedia(\(fileUrl))")
|
||||
do {
|
||||
try await sdk.transferMedia(fileUrl: fileUrl)
|
||||
log("transfer ✓", level: .success)
|
||||
} catch {
|
||||
log("transfer ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.transferMedia(fileUrl: fileUrl) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success:
|
||||
self.log("transfer ✓", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("transfer ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.deleteFile(key:) → DeleteFileResponse
|
||||
private func runDeleteFile(_ rawKey: String) async {
|
||||
private func runDeleteFile(_ rawKey: String) {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ deleteFile(\(rawKey))")
|
||||
do {
|
||||
_ = try await sdk.deleteFile(key: rawKey)
|
||||
log("deleteFile ✓", level: .success)
|
||||
deviceFiles.removeAll { $0 == rawKey }
|
||||
} catch {
|
||||
log("deleteFile ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.deleteFile(key: rawKey) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success:
|
||||
self.log("deleteFile ✓", level: .success)
|
||||
self.deviceFiles.removeAll { $0 == rawKey }
|
||||
case .failure(let error):
|
||||
self.log("deleteFile ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sdk.fetchLatestFirmware(identifier:status:) → FirmwareInfo?
|
||||
private func runFetchFirmwareInfo() async {
|
||||
private func runFetchFirmwareInfo() {
|
||||
let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !brand.isEmpty else {
|
||||
firmwareError = "No identifier specified"
|
||||
@@ -424,34 +437,41 @@ struct WrapperTestView: View {
|
||||
}
|
||||
firmwareLoading = true
|
||||
firmwareError = nil
|
||||
defer { firmwareLoading = false }
|
||||
log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))")
|
||||
do {
|
||||
let info = try await sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus)
|
||||
firmwareInfo = info
|
||||
if let info {
|
||||
let hasUpdate = sdk.hasNewerFirmware(deviceVersion: sdk.version, serverVersion: info.version)
|
||||
log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success)
|
||||
} else {
|
||||
log("no firmware available", level: .info)
|
||||
viewModel.sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.firmwareLoading = false
|
||||
switch result {
|
||||
case .success(let info):
|
||||
self.firmwareInfo = info
|
||||
if let info = info {
|
||||
let hasUpdate = self.viewModel.sdk.hasNewerFirmware(deviceVersion: self.viewModel.version, serverVersion: info.version)
|
||||
self.log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success)
|
||||
} else {
|
||||
self.log("no firmware available", level: .info)
|
||||
}
|
||||
case .failure(let error):
|
||||
self.firmwareError = error.localizedDescription
|
||||
self.log("firmware info ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
firmwareError = error.localizedDescription
|
||||
log("firmware info ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 固件升级第二步:sdk.upgradeFirmware(fileUrl:)
|
||||
private func runUpgradeFirmware() async {
|
||||
private func runUpgradeFirmware() {
|
||||
guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return }
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
log("→ upgradeFirmware \(info.version)")
|
||||
do {
|
||||
try await sdk.upgradeFirmware(fileUrl: info.fileUrl)
|
||||
log("OTA ✓", level: .success)
|
||||
} catch {
|
||||
log("OTA ✗ \(error.localizedDescription)", level: .error)
|
||||
viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
switch result {
|
||||
case .success:
|
||||
self.log("OTA ✓", level: .success)
|
||||
case .failure(let error):
|
||||
self.log("OTA ✗ \(error.localizedDescription)", level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user