XuqmGroup-iOSSDK/Sources/XuqmFileSDK/FileSDK.swift
XuqmGroup 5b34c60838 feat(sdk): 新增文件上传下载功能并完善WebView组件
- 在Android SDK中新增FileSDK模块,提供统一的文件上传、下载、打开接口
- 实现Android端文件下载到沙盒目录或公共Downloads目录,并支持通知栏进度显示
- 完善Android WebView组件,增加文件选择、拍照、下载拦截、H5双向通信能力
- 在iOS SDK中新增XuqmFileSDK模块,提供文件上传下载功能
- 实现iOS端WebView组件的文件下载拦截和原生文件选择器集成
- 更新文档说明Android和iOS SDK的文件操作API使用方法
- 重构iOS SDK项目结构,按功能拆分为多个独立模块便于集成
- 添加文件下载进度通知和完成后的文件打开功能
2026-06-05 15:48:08 +08:00

181 行
6.7 KiB
Swift

import Foundation
import XuqmCoreSDK
import UniformTypeIdentifiers
public struct FileUploadResult: Codable, Sendable {
public let url: String
public let thumbnailUrl: String?
public let hash: String
public let size: Int64
public let originalName: String?
public let mimeType: String?
public let ext: String?
}
public enum FileDownloadDestination: Sendable {
/// App's Documents directory private to the app, visible in Files under the app name.
case sandbox
/// Prompts the user with a system save panel (UIDocumentPickerViewController) to choose any location.
case userPick
}
public final class FileSDK: @unchecked Sendable {
public static let shared = FileSDK()
private init() {}
// MARK: - Download
public func download(
url: String,
fileName: String? = nil,
destination: FileDownloadDestination = .sandbox,
onProgress: @Sendable @escaping (Int) -> Void = { _ in }
) async throws -> URL {
guard let downloadURL = URL(string: url) else {
throw URLError(.badURL)
}
let resolvedName = fileName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
?? downloadURL.lastPathComponent.components(separatedBy: "?").first?.nilIfEmpty
?? "download.bin"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + resolvedName)
let (asyncBytes, response) = try await URLSession.shared.bytes(from: downloadURL)
let totalSize = (response as? HTTPURLResponse)?.expectedContentLength ?? -1
var received: Int64 = 0
var buffer = Data()
buffer.reserveCapacity(totalSize > 0 ? Int(min(totalSize, 1024 * 1024)) : 65536)
for try await byte in asyncBytes {
buffer.append(byte)
received += 1
if totalSize > 0 {
onProgress(Int(received * 100 / totalSize))
}
}
onProgress(100)
try buffer.write(to: tempURL)
switch destination {
case .sandbox:
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let target = uniqueURL(in: docsDir, name: resolvedName)
try FileManager.default.moveItem(at: tempURL, to: target)
return target
case .userPick:
// Return the temp URL; caller must present UIDocumentPickerViewController.
// The picker moves/copies the file to the user-chosen location.
return tempURL
}
}
public func saveBlobDownload(
base64Data: String,
fileName: String,
destination: FileDownloadDestination = .sandbox
) throws -> URL {
guard let data = Data(base64Encoded: base64Data) else {
throw CocoaError(.fileReadCorruptFile)
}
switch destination {
case .sandbox:
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let target = uniqueURL(in: docsDir, name: fileName.nilIfEmpty ?? "download.bin")
try data.write(to: target)
return target
case .userPick:
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + "_" + (fileName.nilIfEmpty ?? "download.bin"))
try data.write(to: tempURL)
return tempURL
}
}
// MARK: - Upload
public func upload(fileURL: URL, thumbnailData: Data? = nil) async throws -> FileUploadResult {
_ = await XuqmSDK.shared.requireConfig()
let tokenStore = await XuqmSDK.shared.tokenStore
let boundary = "Boundary-\(UUID().uuidString)"
let url = SDKEndpoints.apiBaseURL.appendingPathComponent("api/file/upload")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
if let token = tokenStore?.get() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let body = try createMultipartBody(fileURL: fileURL, boundary: boundary, thumbnailData: thumbnailData)
let (data, response) = try await URLSession.shared.upload(for: request, from: body)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
let wrapper = try JSONDecoder().decode(ApiResponse<FileUploadResult>.self, from: data)
guard let result = wrapper.data else {
throw URLError(.cannotDecodeContentData)
}
return result
}
private func createMultipartBody(fileURL: URL, boundary: String, thumbnailData: Data?) throws -> Data {
var body = Data()
let filename = fileURL.lastPathComponent
let mimeType: String
if #available(iOS 14.0, macOS 11.0, *) {
if let uti = UTType(filenameExtension: (fileURL.path as NSString).pathExtension),
let preferred = uti.preferredMIMEType {
mimeType = preferred
} else {
mimeType = "application/octet-stream"
}
} else {
mimeType = "application/octet-stream"
}
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n")
body.append("Content-Type: \(mimeType)\r\n\r\n")
body.append(try Data(contentsOf: fileURL))
body.append("\r\n")
if let thumbnailData {
let thumbFilename = "\((filename as NSString).deletingPathExtension)_thumb.jpg"
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"thumbnail\"; filename=\"\(thumbFilename)\"\r\n")
body.append("Content-Type: image/jpeg\r\n\r\n")
body.append(thumbnailData)
body.append("\r\n")
}
body.append("--\(boundary)--\r\n")
return body
}
}
private extension Data {
mutating func append(_ string: String) {
if let data = string.data(using: .utf8) {
append(data)
}
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
private func uniqueURL(in directory: URL, name: String) -> URL {
let base = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
let extSuffix = ext.isEmpty ? "" : ".\(ext)"
var candidate = directory.appendingPathComponent(name)
var index = 1
while FileManager.default.fileExists(atPath: candidate.path) {
candidate = directory.appendingPathComponent("\(base)(\(index))\(extSuffix)")
index += 1
}
return candidate
}