import SwiftUI import DuooomiBleSDK // MARK: - CDN Helper private enum CDNHelper { static let host = "https://cdn.bowong.cc/" static func ensureFullUrl(_ keyOrUrl: String) -> String { if keyOrUrl.hasPrefix("http://") || keyOrUrl.hasPrefix("https://") { return keyOrUrl } let key = keyOrUrl.hasPrefix("/") ? String(keyOrUrl.dropFirst()) : keyOrUrl return host + key } static func isImage(_ url: String) -> Bool { let ext = URL(string: url)?.pathExtension.lowercased() ?? "" return ["jpg", "jpeg", "png", "webp", "gif"].contains(ext) } } // MARK: - View struct WrapperTestView: View { @EnvironmentObject var sdk: DuooomiBleSDK @State private var userId = "test-user-001" @State private var videoUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" @State private var aniUrlDirect = "https://cdn.bowong.cc/material/c59affcbe0654bf8b69aaadc1900ac3f.ani" // Firmware update @State private var firmwareInfo: FirmwareInfo? = nil @State private var firmwareLoading = false @State private var firmwareError: String? = nil @State private var isBusy = false @State private var connectingDeviceId: String? @State private var deviceFiles: [String] = [] @State private var logs: [LogLine] = [] struct LogLine: Identifiable { let id = UUID() let time = Date() let level: Level let message: String enum Level { case info, success, error } } var body: some View { List { statusSection scanSection deviceSection fileSection directAniSection firmwareSection deviceFilesSection logSection } .navigationTitle("Native SDK Test") .scrollDismissesKeyboard(.interactively) .onTapGesture { UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) } } // MARK: - Sections private var statusSection: some View { Section("Status") { LabeledContent("btState", value: sdk.btState.rawValue) LabeledContent("isActivated", value: sdk.isActivated ? "YES" : "NO") LabeledContent("busy", value: isBusy ? "YES" : "no") if let err = sdk.error { Text(err).foregroundStyle(.red).font(.caption) } } } private var scanSection: some View { Section("Scan") { HStack { Button("Scan") { sdk.scan() log("→ scan()") } .buttonStyle(.borderedProminent) Button("Stop") { sdk.stopScan() log("→ stopScan()") } .buttonStyle(.bordered) } ForEach(sdk.discoveredDevices) { device in HStack { VStack(alignment: .leading) { Text(device.name ?? "Unknown") Text(device.id).font(.caption2).foregroundStyle(.secondary) } Spacer() Text("\(device.rssi) dBm") .font(.caption) .foregroundStyle(.secondary) if sdk.connectedDevice?.id == device.id { Button("Disconnect") { Task { await runDisconnect() } } .buttonStyle(.bordered) .controlSize(.small) .tint(.red) } else if connectingDeviceId == device.id { ProgressView().controlSize(.small) } else { Button("Connect") { Task { await runConnect(deviceId: device.id) } } .buttonStyle(.bordered) .controlSize(.small) .disabled(connectingDeviceId != nil) } } } } } private var deviceSection: some View { Section("Device") { LabeledContent("Name", value: sdk.connectedDevice?.name ?? "—") LabeledContent("ID", value: sdk.connectedDevice?.id ?? "—") 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("Version", value: sdk.version.isEmpty ? "—" : sdk.version) HStack { Button("getDeviceInfo") { Task { await runGetDeviceInfo() } } Button("getVersion") { Task { await runGetVersion() } } } .buttonStyle(.bordered) HStack { TextField("userId", text: $userId) .textFieldStyle(.roundedBorder) Button("bind") { Task { await runBind() } } .buttonStyle(.bordered) Button("unbind") { Task { await runUnbind() } } .buttonStyle(.bordered) } } } private var fileSection: some View { Section("Transfer") { if sdk.transferProgress > 0 { ProgressView(value: Double(sdk.transferProgress), total: 100) { Text("Transfer: \(sdk.transferProgress)%").font(.caption) } } TextField("videoUrl (source)", text: $videoUrl) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) Button("Convert → PrepareTransfer → Transfer") { Task { await runConvertAndTransfer() } } .buttonStyle(.borderedProminent) .disabled(videoUrl.isEmpty || sdk.connectedDevice == nil) } } private var directAniSection: some View { Section("Direct ANI Transfer") { TextField("aniUrl (https://... .ani)", text: $aniUrlDirect) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) Button("Prepare → Transfer ANI") { Task { await runTransferAniDirect() } } .buttonStyle(.borderedProminent) .disabled(aniUrlDirect.isEmpty || sdk.connectedDevice == nil || isBusy) } } private var firmwareSection: some View { Section("Firmware Update") { LabeledContent("Current Version", value: sdk.version.isEmpty ? "—" : sdk.version) LabeledContent("Brand", value: sdk.deviceInfo?.brand ?? "—") if let info = firmwareInfo { VStack(alignment: .leading, spacing: 6) { Text("Latest: \(info.version)") Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary) if let notes = info.notes, !notes.isEmpty { Text(notes).font(.caption) } } } if let err = firmwareError { Text(err).font(.caption).foregroundStyle(.red) } HStack { Button(firmwareLoading ? "Fetching..." : "Get Update Info") { Task { await runFetchFirmwareInfo() } } .buttonStyle(.bordered) .disabled(firmwareLoading || sdk.connectedDevice == nil || sdk.deviceInfo?.brand == nil) Button("Upgrade Firmware") { Task { await runUpgradeFirmware() } } .buttonStyle(.borderedProminent) .disabled(firmwareInfo?.fileUrl.isEmpty != false || sdk.connectedDevice == nil || isBusy) } } } private var deviceFilesSection: some View { Section("Device Files (\(deviceFiles.count))") { if deviceFiles.isEmpty { Text("Bind 后显示设备文件") .font(.caption) .foregroundStyle(.secondary) } ForEach(deviceFiles, id: \.self) { rawKey in let fileUrl = CDNHelper.ensureFullUrl(rawKey) HStack { if CDNHelper.isImage(fileUrl), let url = URL(string: fileUrl) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) case .failure: Image(systemName: "photo").foregroundStyle(.secondary) default: ProgressView() } } .frame(width: 50, height: 50) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { Image(systemName: "film") .frame(width: 50, height: 50) .foregroundStyle(.secondary) } Text(URL(string: fileUrl)?.lastPathComponent ?? fileUrl) .font(.caption) .lineLimit(1) Spacer() Button(role: .destructive) { Task { await runDeleteFile(rawKey) } } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .disabled(isBusy) } } } } private var logSection: some View { Section("Log (\(logs.count))") { ForEach(logs.prefix(80)) { line in Text(line.message) .font(.caption) .foregroundStyle(color(for: line.level)) } } } private func color(for level: LogLine.Level) -> Color { switch level { case .info: return .primary case .success: return .green case .error: return .red } } // MARK: - Logging private func log(_ message: String, level: LogLine.Level = .info) { logs.insert(LogLine(level: level, message: message), at: 0) if logs.count > 200 { logs.removeLast() } } // MARK: - Async runners private func runConnect(deviceId: String) async { 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) } } private func runDisconnect() async { log("→ disconnect") do { try await sdk.disconnect() deviceFiles = [] log("disconnect ✓", level: .success) } catch { log("disconnect ✗ \(error.localizedDescription)", level: .error) } } private func runGetDeviceInfo() async { isBusy = true defer { isBusy = false } log("→ getDeviceInfo") do { let info = try await sdk.getDeviceInfo() log( "getDeviceInfo ✓ brand=\(info.brand) size=\(info.size) battery=\(info.powerlevel)", level: .success ) } catch { log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) } } private func runGetVersion() async { 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) } } private func runBind() async { 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) } } private func runUnbind() async { 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) } } private func runConvertAndTransfer() async { isBusy = true defer { isBusy = false } do { // 1. Convert log("→ AniConverter.convert") let aniUrl = try await AniConverter.convert(videoUrl: videoUrl) log("ani url: \(aniUrl)", level: .success) // 2. HEAD request to get ani file size log("→ HEAD \(aniUrl)") let aniSize = try await getFileSize(url: aniUrl) log("ani size: \(aniSize) bytes") // 3. key = source video full URL let key = videoUrl log("key: \(key)") // 4. prepareTransfer log("→ prepareTransfer") let prepareResult = try await sdk.prepareTransfer(key: key, size: aniSize) log("prepareTransfer → status=\(prepareResult.status)", level: .success) guard prepareResult.status == "ready" else { log("device not ready: \(prepareResult.status)", level: .error) return } // 5. transferFile log("→ transferFile") try await sdk.transferFile(fileUri: aniUrl) log("transferFile ✓", level: .success) } catch { log("transfer failed: \(error.localizedDescription)", level: .error) } } private func runTransferAniDirect() async { isBusy = true defer { isBusy = false } do { let aniUrl = aniUrlDirect.trimmingCharacters(in: .whitespacesAndNewlines) guard !aniUrl.isEmpty else { return } // 1) 获取 ani 文件大小(优先 HEAD,失败则直读数据长度) log("→ HEAD \(aniUrl)") var aniSize: Int = 0 do { aniSize = try await getFileSize(url: aniUrl) } catch { aniSize = 0 } if aniSize <= 0, let url = URL(string: aniUrl) { log("HEAD 无法获取大小,回退为直接下载计算") let (data, response) = try await URLSession.shared.data(from: url) if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) { aniSize = data.count } else { throw AniConverterError(message: "下载 ani 失败") } } log("ani size: \(aniSize) bytes") // 2) 使用 aniUrl 作为 key 进行 prepare let key = aniUrl log("key: \(key)") log("→ prepareTransfer") let prepareResult = try await sdk.prepareTransfer(key: key, size: aniSize) log("prepareTransfer → status=\(prepareResult.status)", level: .success) guard prepareResult.status == "ready" else { log("device not ready: \(prepareResult.status)", level: .error) return } // 3) 传输 ANI 文件 log("→ transferFile (ANI)") try await sdk.transferFile(fileUri: aniUrl, commandType: .transferAniVideo) log("transferFile ✓", level: .success) } catch { log("transfer failed: \(error.localizedDescription)", level: .error) } } private func runDeleteFile(_ rawKey: String) async { 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) } } private func runFetchFirmwareInfo() async { guard let brand = sdk.deviceInfo?.brand, !brand.isEmpty else { firmwareError = "No device brand" return } firmwareLoading = true firmwareError = nil defer { firmwareLoading = false } log("→ fetch firmware info for \(brand)") do { let info = try await FirmwareUpdateService.fetchLatest(brand: brand) firmwareInfo = info log("firmware latest: \(info.version)", level: .success) } catch { firmwareError = error.localizedDescription log("firmware info ✗ \(error.localizedDescription)", level: .error) } } private func runUpgradeFirmware() async { guard let url = firmwareInfo?.fileUrl, !url.isEmpty else { return } isBusy = true defer { isBusy = false } log("→ OTA transfer") do { try await sdk.transferFile(fileUri: url, commandType: .otaPackage) log("OTA ✓", level: .success) } catch { log("OTA ✗ \(error.localizedDescription)", level: .error) } } private func getFileSize(url: String) async throws -> Int { guard let fileUrl = URL(string: url) else { throw AniConverterError(message: "Invalid URL: \(url)") } var request = URLRequest(url: fileUrl) request.httpMethod = "HEAD" let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw AniConverterError(message: "Invalid response") } return Int(http.expectedContentLength) } }