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