demo app + SDK updates:\n- Add direct ANI transfer (prepare -> transfer)\n- Add firmware update flow (fetch latest w/ x-api-key, OTA transfer)\n- Extract x-api-key to NetworkConstants\n- Rename example app to demo; move folder and entry to DemoApp\n- Switch from SPM package dependency to local framework target\n- Enable automatic Info.plist generation for framework target\n- Add placeholder test to satisfy SwiftPM test target\n- Regenerate XcodeGen project (demo.xcodeproj)

This commit is contained in:
km2023
2026-04-09 18:41:56 +08:00
parent c33e7d3fa1
commit 7d9ff3081d
30 changed files with 2594 additions and 516 deletions

65
demo/Info.plist Normal file
View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>demo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>duooomi</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>api.mixvideo.bowong.cc</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>cdn.bowong.cc</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app needs Bluetooth to communicate with your device</string>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>

View File

@@ -0,0 +1,84 @@
import Foundation
/// ANI
struct AniConverterError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
/// ANI
///
/// duooomi-ble-sdk/CLAUDE.md " > ANI API"
/// `videoUrl` `width / height / fps`
enum AniConverter {
private static let endpoint = URL(string: "https://api.mixvideo.bowong.cc/api/auth/loomart/file/convert-to-ani")!
/// session ANI -1005
private static let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 120
config.timeoutIntervalForResource = 180
config.waitsForConnectivity = true
return URLSession(configuration: config)
}()
/// ANI ani CDN URL
/// - Parameter videoUrl: URL
/// - Returns: ani CDN URL
/// - Throws:
/// - Note: -1005
static func convert(videoUrl: String) async throws -> String {
do {
return try await performConvert(videoUrl: videoUrl)
} catch let error as NSError where error.code == NSURLErrorNetworkConnectionLost {
// -1005
return try await performConvert(videoUrl: videoUrl)
}
}
///
/// - Parameter videoUrl: URL
/// - Returns: ani URL
/// - Throws:
private static func performConvert(videoUrl: String) async throws -> String {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.setValue(NetworkConfig.apiKey, forHTTPHeaderField: "x-api-key")
request.timeoutInterval = 120
let body: [String: Any] = [
"videoUrl": videoUrl,
"width": 360,
"height": 360,
"fps": "24",
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw AniConverterError(message: "HTTP \(http.statusCode)")
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw AniConverterError(message: "Invalid JSON response")
}
guard (json["success"] as? Bool) == true,
let outer = json["data"] as? [String: Any] else {
throw AniConverterError(message: "ANI API reported failure")
}
if (outer["status"] as? Bool) != true {
let msg = (outer["msg"] as? String) ?? "ANI conversion failed"
throw AniConverterError(message: msg)
}
guard let aniUrl = outer["data"] as? String, !aniUrl.isEmpty else {
throw AniConverterError(message: "Missing ani url in response")
}
return aniUrl
}
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
import DuooomiBleSDK
@main
struct DemoApp: App {
@StateObject private var sdk = DuooomiBleSDK()
var body: some Scene {
WindowGroup {
NavigationView {
WrapperTestView()
}
.environmentObject(sdk)
}
}
}

View File

@@ -0,0 +1,68 @@
import Foundation
struct FirmwareInfo: Codable, Equatable {
let version: String
let fileUrl: String
let notes: String?
}
enum FirmwareServiceError: LocalizedError {
case invalidBrand
case invalidResponse
case http(Int)
case network(String)
var errorDescription: String? {
switch self {
case .invalidBrand: return "Invalid brand"
case .invalidResponse: return "Invalid firmware info response"
case .http(let code): return "HTTP error: \(code)"
case .network(let msg): return msg
}
}
}
enum FirmwareUpdateService {
//
private static let base = NetworkConfig.apiHost
private static let latestPath = "/api/auth/firmware/latest"
///
/// - Parameters:
/// - brand: Bowong
/// - status: `PUBLISHED`
/// - Returns:
/// - Throws: /HTTP
static func fetchLatest(brand: String, status: String = "PUBLISHED") async throws -> FirmwareInfo {
guard !brand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw FirmwareServiceError.invalidBrand
}
var comps = URLComponents(url: base.appendingPathComponent(latestPath), resolvingAgainstBaseURL: false)!
comps.queryItems = [
URLQueryItem(name: "brand", value: brand),
URLQueryItem(name: "status", value: status)
]
guard let url = comps.url else { throw FirmwareServiceError.invalidResponse }
var req = URLRequest(url: url)
req.httpMethod = "GET"
req.setValue(NetworkConfig.apiKey, forHTTPHeaderField: "x-api-key")
req.setValue("application/json", forHTTPHeaderField: "accept")
do {
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse else { throw FirmwareServiceError.invalidResponse }
guard (200...299).contains(http.statusCode) else {
throw FirmwareServiceError.http(http.statusCode)
}
// { version: String, fileUrl: String, notes?: String }
let info = try JSONDecoder().decode(FirmwareInfo.self, from: data)
return info
} catch let e as FirmwareServiceError {
throw e
} catch {
throw FirmwareServiceError.network(error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
enum NetworkConfig {
/// Shared API host for mixvideo endpoints
static let apiHost = URL(string: "https://api.mixvideo.bowong.cc")!
/// Shared API key used across ANI conversion and firmware APIs
static let apiKey = "gsUBjxphRjvpERpLiMSUFXERSsuqXvPTkzNSnoHgLqzlByabSemmMpTemyvRkRuj"
}

View File

@@ -0,0 +1,526 @@
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)
}
}