feat(sdk): 新增文件上传下载功能并完善WebView组件

- 在Android SDK中新增FileSDK模块,提供统一的文件上传、下载、打开接口
- 实现Android端文件下载到沙盒目录或公共Downloads目录,并支持通知栏进度显示
- 完善Android WebView组件,增加文件选择、拍照、下载拦截、H5双向通信能力
- 在iOS SDK中新增XuqmFileSDK模块,提供文件上传下载功能
- 实现iOS端WebView组件的文件下载拦截和原生文件选择器集成
- 更新文档说明Android和iOS SDK的文件操作API使用方法
- 重构iOS SDK项目结构,按功能拆分为多个独立模块便于集成
- 添加文件下载进度通知和完成后的文件打开功能
这个提交包含在:
XuqmGroup 2026-06-05 15:48:08 +08:00
父节点 25b80ce9e3
当前提交 5b34c60838
共有 5 个文件被更改,包括 332 次插入32 次删除

查看文件

@ -54,6 +54,7 @@ let package = Package(
), ),
.target( .target(
name: "XuqmWebViewSDK", name: "XuqmWebViewSDK",
dependencies: ["XuqmFileSDK"],
path: "Sources/XuqmWebViewSDK", path: "Sources/XuqmWebViewSDK",
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
), ),

137
README.md
查看文件

@ -10,19 +10,13 @@ XuqmGroup-iOSSDK/
├── XuqmSDK.podspec # CocoaPods 发版描述 ├── XuqmSDK.podspec # CocoaPods 发版描述
├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述 ├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述
├── PUBLISH.md # 发版操作手册 ├── PUBLISH.md # 发版操作手册
├── Sources/XuqmSDK/ ├── Sources/XuqmCoreSDK/ # 核心初始化、HTTP、Token
├── Core/ ├── Sources/XuqmImSDK/ # IMWebSocket 实时通信
│ ├── XuqmSDK.swift # 入口init / setToken ├── Sources/XuqmPushSDK/ # 推送APNs Token 注册
│ ├── SDKConfig.swift # 配置结构体 ├── Sources/XuqmUpdateSDK/ # 版本管理:检查更新
│ ├── ApiClient.swift # HTTP 客户端URLSession ├── Sources/XuqmLicenseSDK/ # 授权管理
│ └── TokenStore.swift # Keychain Token 存储 ├── Sources/XuqmFileSDK/ # 文件上传、下载、本地打开
├── IM/ └── Sources/XuqmWebViewSDK/ # WebView 嵌入式组件 / 独立页面
│ └── ImClient.swift # WebSocket IM 客户端
├── Push/
│ └── PushSDK.swift # APNs Token 注册
└── Update/
└── UpdateSDK.swift # 版本检查 / App 更新
└── Sources/XuqmWebViewSDK/
├── XWebViewBridge.swift # 页面配置 / 控制器桥接 ├── XWebViewBridge.swift # 页面配置 / 控制器桥接
├── XWebViewPage.swift # 独立页面 ├── XWebViewPage.swift # 独立页面
├── XWebViewTypes.swift # 配置 / 控制器协议 ├── XWebViewTypes.swift # 配置 / 控制器协议
@ -264,6 +258,123 @@ if result.needsUpdate, let info = result.info {
--- ---
## XuqmFileSDK
文件上传、下载、本地打开的统一入口。`XuqmImSDK` 和 `XuqmWebViewSDK` 均依赖此模块。
### 上传
```swift
import XuqmFileSDK
let result: FileUploadResult = try await FileSDK.shared.upload(
fileURL: localFileURL,
thumbnailData: thumbnailJpegData // 可选
)
// result.url / .thumbnailUrl / .hash / .size / .mimeType / .ext
```
### 下载
```swift
// 下载存储目标
public enum FileDownloadDestination: Sendable {
case sandbox // App Documents 目录(在 Files App 中以 App 名称显示)
case userPick // 弹出系统存储面板,由用户选择保存位置
}
// URL 下载,实时进度回调
let localURL: URL = try await FileSDK.shared.download(
url: "https://example.com/report.pdf",
fileName: "report.pdf", // 可选,默认从 URL 推断
destination: .sandbox,
onProgress: { progress in // 0–100
print("下载进度:\(progress)%")
}
)
// Blob / base64 数据写盘
let localURL: URL = try FileSDK.shared.saveBlobDownload(
base64Data: b64String,
fileName: "export.xlsx",
destination: .userPick
)
```
---
## XuqmWebViewSDK
### XWebViewConfig 完整参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `url` | String | `""` | 初始加载地址 |
| `title` | String | `""` | 页面标题(独立页面模式使用) |
| `hideToolbar` | Bool | `false` | 隐藏独立页面顶栏 |
| `hideStatusBar` | Bool | `false` | 隐藏状态栏 |
| `userAgent` | String? | `nil` | 自定义 User-Agent |
| `injectedJavaScript` | String? | `nil` | 页面加载后注入的额外 JS |
| `downloadDestination` | FileDownloadDestination | `.sandbox` | 下载文件存储目标 |
| `onMessage` | `(String) -> Void`? | `nil` | H5 发送消息的回调 |
### 文件选择与拍照
WKWebView 原生支持 `<input type="file">`,SDK 额外处理:
- `<input type="file" capture>` 摄像头iOS 15+ 通过 `requestMediaCapturePermissionFor` 自动授权
- 麦克风 / 摄像头 WebRTC `getUserMedia()`:自动授权
### 下载拦截与存储
```swift
XWebViewView(
config: XWebViewConfig(
url: "https://example.com",
downloadDestination: .userPick // 弹出系统 Files 存储面板
)
)
```
- `.sandbox`:下载完成后弹出 `UIActivityViewController`(分享 / 存储到 Files
- `.userPick`:下载完成后弹出 `UIDocumentPickerViewController`(直接选择存储位置)
### H5 监听下载进度
```javascript
// 下载进度0–100
window.addEventListener('__xwvDownloadProgress', (e) => {
console.log(e.detail.url, e.detail.progress)
})
// 下载完成
window.addEventListener('__xwvDownloadDone', (e) => {
if (e.detail.success) {
console.log('下载成功', e.detail.url)
} else {
console.error('下载失败', e.detail.error)
}
})
```
### H5 ↔ Native 消息通信
```javascript
// H5 → Native触发 onMessage 回调)
window.webkit.messageHandlers.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'login', token: '...' })
)
```
```swift
// Native → H5
XWebViewBridge.shared.currentController()?.postMessageToWeb(
"window.dispatchEvent(new CustomEvent('nativeMsg', { detail: { key: 'value' } }))"
)
```
---
## 发版 ## 发版
### SPM推荐 ### SPM推荐

查看文件

@ -12,12 +12,89 @@ public struct FileUploadResult: Codable, Sendable {
public let ext: 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 final class FileSDK: @unchecked Sendable {
public static let shared = FileSDK() public static let shared = FileSDK()
private init() {} 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 { public func upload(fileURL: URL, thumbnailData: Data? = nil) async throws -> FileUploadResult {
let config = await XuqmSDK.shared.requireConfig() _ = await XuqmSDK.shared.requireConfig()
let tokenStore = await XuqmSDK.shared.tokenStore let tokenStore = await XuqmSDK.shared.tokenStore
let boundary = "Boundary-\(UUID().uuidString)" let boundary = "Boundary-\(UUID().uuidString)"
@ -84,3 +161,20 @@ private extension 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
}

查看文件

@ -1,4 +1,5 @@
import Foundation import Foundation
import XuqmFileSDK
public struct XWebViewConfig: Sendable { public struct XWebViewConfig: Sendable {
public var url: String public var url: String
@ -7,6 +8,8 @@ public struct XWebViewConfig: Sendable {
public var hideStatusBar: Bool public var hideStatusBar: Bool
public var userAgent: String? public var userAgent: String?
public var injectedJavaScript: String? public var injectedJavaScript: String?
/// Where intercepted WebView downloads are saved.
public var downloadDestination: FileDownloadDestination
public var onMessage: (@Sendable (String) -> Void)? public var onMessage: (@Sendable (String) -> Void)?
public init( public init(
@ -16,6 +19,7 @@ public struct XWebViewConfig: Sendable {
hideStatusBar: Bool = false, hideStatusBar: Bool = false,
userAgent: String? = nil, userAgent: String? = nil,
injectedJavaScript: String? = nil, injectedJavaScript: String? = nil,
downloadDestination: FileDownloadDestination = .sandbox,
onMessage: (@Sendable (String) -> Void)? = nil onMessage: (@Sendable (String) -> Void)? = nil
) { ) {
self.url = url self.url = url
@ -24,6 +28,7 @@ public struct XWebViewConfig: Sendable {
self.hideStatusBar = hideStatusBar self.hideStatusBar = hideStatusBar
self.userAgent = userAgent self.userAgent = userAgent
self.injectedJavaScript = injectedJavaScript self.injectedJavaScript = injectedJavaScript
self.downloadDestination = downloadDestination
self.onMessage = onMessage self.onMessage = onMessage
} }
} }

查看文件

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import XuqmFileSDK
// JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native. // JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native.
private let kBridgeName = "ReactNativeWebView" private let kBridgeName = "ReactNativeWebView"
@ -181,18 +182,96 @@ public struct XWebViewView: UIViewRepresentable {
didReceive message: WKScriptMessage didReceive message: WKScriptMessage
) { ) {
guard let raw = message.body as? String else { return } guard let raw = message.body as? String else { return }
// Filter internal __xwv messages; forward the rest to onMessage. guard let data = raw.data(using: .utf8),
if let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let xwv = json["__xwv"] as? String {
switch xwv {
case "log": return // debug only, not forwarded
case "alert", "confirm", "prompt", "download", "blobdownload", "bloberror": return
default: break
}
}
config.onMessage?(raw) config.onMessage?(raw)
return
} }
guard let xwv = json["__xwv"] as? String else {
config.onMessage?(raw)
return
}
switch xwv {
case "log", "alert", "confirm", "prompt", "bloberror":
return
case "download":
let url = json["url"] as? String ?? ""
let filename = (json["filename"] as? String)?.nilIfEmpty
Task { await handleDownload(url: url, filename: filename) }
case "blobdownload":
let url = json["url"] as? String ?? ""
let filename = (json["filename"] as? String)?.nilIfEmpty ?? "download.bin"
let b64 = json["data"] as? String ?? ""
Task { await handleBlobDownload(url: url, filename: filename, base64Data: b64) }
default:
break
}
}
private func dispatchDownloadEvent(_ name: String, url: String, extra: String = "") {
let escaped = url.jsEscaped
let js = "window.dispatchEvent(new CustomEvent('\(name)',{detail:{url:'\(escaped)'\(extra)}}));"
webView?.evaluateJavaScript(js, completionHandler: nil)
}
private func handleDownload(url: String, filename: String?) async {
do {
let localURL = try await FileSDK.shared.download(
url: url,
fileName: filename,
destination: config.downloadDestination
) { [weak self] progress in
Task { @MainActor [weak self] in
self?.dispatchDownloadEvent("__xwvDownloadProgress", url: url, extra: ",progress:\(progress)")
}
}
dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:true")
openOrSave(localURL: localURL, destination: config.downloadDestination)
} catch {
let msg = error.localizedDescription.jsEscaped
dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:false,error:'\(msg)'")
}
}
private func handleBlobDownload(url: String, filename: String, base64Data: String) async {
do {
let localURL = try FileSDK.shared.saveBlobDownload(
base64Data: base64Data,
fileName: filename,
destination: config.downloadDestination
)
dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:true")
openOrSave(localURL: localURL, destination: config.downloadDestination)
} catch {
let msg = error.localizedDescription.jsEscaped
dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:false,error:'\(msg)'")
}
}
private func openOrSave(localURL: URL, destination: FileDownloadDestination) {
guard let wv = webView else { return }
#if canImport(UIKit)
switch destination {
case .sandbox:
let vc = UIActivityViewController(activityItems: [localURL], applicationActivities: nil)
topViewController(from: wv)?.present(vc, animated: true)
case .userPick:
let picker = UIDocumentPickerViewController(forExporting: [localURL], asCopy: true)
topViewController(from: wv)?.present(picker, animated: true)
}
#endif
}
#if canImport(UIKit)
private func topViewController(from view: UIView) -> UIViewController? {
var responder: UIResponder? = view
while let r = responder {
if let vc = r as? UIViewController { return vc }
responder = r.next
}
return nil
}
#endif
@available(iOS 15.0, *) @available(iOS 15.0, *)
public func webView( public func webView(
@ -207,6 +286,16 @@ public struct XWebViewView: UIViewRepresentable {
} }
} }
private extension String {
var jsEscaped: String {
replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
}
var nilIfEmpty: String? { isEmpty ? nil : self }
}
#else #else
public struct XWebViewView: View { public struct XWebViewView: View {