主要改动: - 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade 三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为 - bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到 fetchLatestFirmware 内部串;软绑失败自动硬解回滚 - UpgradeRecord 复合主键 (sn, firmwareId) 持久化 + sn 级 in-flight 互斥 - DuooomiBleSDKDelegate 新增 didUpdateUpgradeRecords,集成方 可观察对账上报结果(reported/outcome 状态) - DuooomiBleSDK 主类按职责拆 5 个 extension 文件,公开 API 不变 - TargetFirmware 简化为只取必要字段 + 新增 forceUpgrade - scanNamePrefix 改为必填 String - demo 秘钥移至本地 DemoSecrets.swift (gitignored) - README 重写:完整 OTA 流程示例 + 上报状态判定表 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
9.3 KiB
Swift
221 lines
9.3 KiB
Swift
import Foundation
|
||
|
||
// MARK: - File Transfer (含 ANI 转换 / mime 校验 / 远程 size 探测)
|
||
|
||
extension DuooomiBleSDK {
|
||
|
||
static let supportedMediaExtensions: Set<String> = [
|
||
"jpeg", "jpg", "png", "gif", "mp4", "webm", "webp"
|
||
]
|
||
|
||
static let supportedMimeTypes: Set<String> = [
|
||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||
"video/mp4", "video/webm"
|
||
]
|
||
|
||
/// 把已就绪的文件 URI 直接传输给设备(**底层入口**,不做格式校验/转码/prepareTransfer)。
|
||
///
|
||
/// 一般集成方应使用 `transferMedia(fileUrl:)`,由它自动串好"格式校验 → ANI 转换 → prepareTransfer → transferFile"。
|
||
///
|
||
/// - Parameters:
|
||
/// - fileUri: 已就绪文件 URL(HTTPS 远程或 file:// 本地)
|
||
/// - commandType: 默认 `.transferAniVideo`;OTA 烧录用 `.otaPackage`
|
||
/// - completion: 回调 `Result<Void, Error>`。成功时进度先到 100%。
|
||
/// - 失败:`.notConnected` / `.transferFailed(...)` / 各种 IO 错误
|
||
/// - Note: 进度通过 delegate `didUpdateProgress(0...100)` 回调,每 10% 一条 BleLog。
|
||
public func transferFile(
|
||
fileUri: String,
|
||
commandType: CommandType = .transferAniVideo,
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) {
|
||
guard connectedDevice != nil else {
|
||
completion(.failure(DuooomiBleError.notConnected))
|
||
return
|
||
}
|
||
transferProgress = 0
|
||
BleLog.i("Transfer start: uri=\(fileUri), cmd=\(commandType)", "Transfer")
|
||
|
||
fileTransferService.transferFile(
|
||
fileUri: fileUri,
|
||
commandType: commandType,
|
||
onProgress: { [weak self] progress in
|
||
DispatchQueue.main.async {
|
||
let p = Int(progress * 100)
|
||
self?.transferProgress = p
|
||
if p % 10 == 0 {
|
||
BleLog.d("Progress: \(p)%", "Transfer")
|
||
}
|
||
}
|
||
},
|
||
completion: { [weak self] result in
|
||
if case .success = result {
|
||
self?.transferProgress = 100
|
||
BleLog.i("Transfer completed", "Transfer")
|
||
}
|
||
completion(result)
|
||
}
|
||
)
|
||
}
|
||
|
||
/// 一步完成媒体传输:格式校验 → ANI 转换(图片/视频)→ prepareTransfer → transferFile。
|
||
///
|
||
/// 支持的媒体格式:JPEG / PNG / GIF / MP4 / WebM / WebP。先按 URL 扩展名匹配,无扩展名时
|
||
/// 通过 HEAD 请求看 `Content-Type`。`.ani` 后缀直传,跳过转换。
|
||
///
|
||
/// - Parameters:
|
||
/// - fileUrl: HTTPS 媒体 URL(任何支持格式);`.ani` 后缀直传
|
||
/// - completion: 回调 `Result<Void, Error>`
|
||
/// - 失败:`.notConnected` / `.unsupportedFormat(...)` / `.transferFailed("ANI conversion failed: ...")`
|
||
/// / `.prepareTransferFailed(status:)` / `.transferFailed("Cannot determine file size: ...")`
|
||
/// - Note: 进度通过 delegate `didUpdateProgress(0...100)` 回调。
|
||
public func transferMedia(fileUrl: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||
guard connectedDevice != nil else {
|
||
completion(.failure(DuooomiBleError.notConnected))
|
||
return
|
||
}
|
||
let url = fileUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !url.isEmpty else {
|
||
completion(.failure(DuooomiBleError.transferFailed("Empty file URL")))
|
||
return
|
||
}
|
||
|
||
let isAni = url.lowercased().hasSuffix(".ani")
|
||
|
||
if isAni {
|
||
BleLog.i("Direct ANI transfer: \(url)", "Transfer")
|
||
transferMediaStep2(aniUrl: url, originalUrl: url, completion: completion)
|
||
} else {
|
||
validateMediaFormat(url: url) { [weak self] result in
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success:
|
||
BleLog.i("Converting to ANI: \(url)", "Transfer")
|
||
self?.aniConverter.convert(fileUrl: url) { [weak self] result in
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(DuooomiBleError.transferFailed("ANI conversion failed: \(error.localizedDescription)")))
|
||
case .success(let aniUrl):
|
||
BleLog.i("ANI ready: \(aniUrl)", "Transfer")
|
||
self?.transferMediaStep2(aniUrl: aniUrl, originalUrl: url, completion: completion)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func transferMediaStep2(aniUrl: String, originalUrl: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||
getRemoteFileSize(url: aniUrl) { [weak self] result in
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success(let aniSize):
|
||
BleLog.d("ANI size: \(aniSize) bytes", "Transfer")
|
||
self?.prepareTransfer(key: originalUrl, size: aniSize) { [weak self] result in
|
||
switch result {
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
case .success:
|
||
self?.transferFile(fileUri: aniUrl, commandType: .transferAniVideo, completion: completion)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func extractExtension(from urlString: String) -> String? {
|
||
guard let comps = URLComponents(string: urlString) else { return nil }
|
||
let path = comps.path
|
||
let ext = (path.lowercased() as NSString).pathExtension
|
||
guard !ext.isEmpty else { return nil }
|
||
return ext
|
||
}
|
||
|
||
private func validateMediaFormat(url: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||
if let ext = extractExtension(from: url) {
|
||
if Self.supportedMediaExtensions.contains(ext) {
|
||
completion(.success(()))
|
||
} else {
|
||
completion(.failure(DuooomiBleError.unsupportedFormat(ext)))
|
||
}
|
||
return
|
||
}
|
||
|
||
guard let requestUrl = URL(string: url) else {
|
||
completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)")))
|
||
return
|
||
}
|
||
var request = URLRequest(url: requestUrl)
|
||
request.httpMethod = "HEAD"
|
||
URLSession.shared.dataTask(with: request) { _, response, error in
|
||
if let error = error {
|
||
completion(.failure(DuooomiBleError.transferFailed("Format check failed: \(error.localizedDescription)")))
|
||
return
|
||
}
|
||
guard let mime = (response as? HTTPURLResponse)?.allHeaderFields["Content-Type"] as? String else {
|
||
completion(.failure(DuooomiBleError.unsupportedFormat("unknown")))
|
||
return
|
||
}
|
||
let baseMime = mime.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() ?? ""
|
||
if Self.supportedMimeTypes.contains(baseMime) {
|
||
completion(.success(()))
|
||
} else {
|
||
completion(.failure(DuooomiBleError.unsupportedFormat(baseMime)))
|
||
}
|
||
}.resume()
|
||
}
|
||
|
||
private func getRemoteFileSize(url: String, completion: @escaping (Result<Int, Error>) -> Void) {
|
||
guard let fileUrl = URL(string: url) else {
|
||
completion(.failure(DuooomiBleError.transferFailed("Invalid URL: \(url)")))
|
||
return
|
||
}
|
||
|
||
var request = URLRequest(url: fileUrl)
|
||
request.httpMethod = "HEAD"
|
||
|
||
URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
|
||
if let error = error {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
if let http = response as? HTTPURLResponse {
|
||
if let lengthStr = http.allHeaderFields["Content-Length"] as? String,
|
||
let length = Int(lengthStr), length > 0 {
|
||
completion(.success(length))
|
||
return
|
||
}
|
||
if http.expectedContentLength > 0 {
|
||
completion(.success(Int(http.expectedContentLength)))
|
||
return
|
||
}
|
||
}
|
||
|
||
self?.getFileSizeViaRange(fileUrl: fileUrl, completion: completion)
|
||
}.resume()
|
||
}
|
||
|
||
private func getFileSizeViaRange(fileUrl: URL, completion: @escaping (Result<Int, Error>) -> Void) {
|
||
var rangeReq = URLRequest(url: fileUrl)
|
||
rangeReq.httpMethod = "GET"
|
||
rangeReq.setValue("bytes=0-0", forHTTPHeaderField: "Range")
|
||
|
||
URLSession.shared.dataTask(with: rangeReq) { _, response, error in
|
||
if let error = error {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
if let http = response as? HTTPURLResponse,
|
||
let rangeHeader = http.allHeaderFields["Content-Range"] as? String,
|
||
let totalStr = rangeHeader.split(separator: "/").last,
|
||
let total = Int(totalStr), total > 0 {
|
||
completion(.success(total))
|
||
return
|
||
}
|
||
completion(.failure(DuooomiBleError.transferFailed("Cannot determine file size: \(fileUrl.absoluteString)")))
|
||
}.resume()
|
||
}
|
||
}
|