Files
duooomi-ios-sdk/demo/Sources/WrapperTestView.swift

527 lines
18 KiB
Swift
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.
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)
}
}