Files
duooomi-ios-sdk/Sources/DuooomiBleSDK/DuooomiBleSDK+Transfer.swift
km2023 d5729a1a41 feat(sdk): OTA 升级流程对齐 expo + 拆 extension 文件分层 + 秘钥本地化
主要改动:
- 新增 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>
2026-05-08 14:46:41 +08:00

221 lines
9.3 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 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: URLHTTPS 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()
}
}