import Foundation // MARK: - File Transfer (含 ANI 转换 / mime 校验 / 远程 size 探测) extension DuooomiBleSDK { static let supportedMediaExtensions: Set = [ "jpeg", "jpg", "png", "gif", "mp4", "webm", "webp" ] static let supportedMimeTypes: Set = [ "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`。成功时进度先到 100%。 /// - 失败:`.notConnected` / `.transferFailed(...)` / 各种 IO 错误 /// - Note: 进度通过 delegate `didUpdateProgress(0...100)` 回调,每 10% 一条 BleLog。 public func transferFile( fileUri: String, commandType: CommandType = .transferAniVideo, completion: @escaping (Result) -> 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` /// - 失败:`.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) { 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) { 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) { 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) -> 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) -> 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() } }