feat(sdk): 新增文件上传下载功能并完善WebView组件
- 在Android SDK中新增FileSDK模块,提供统一的文件上传、下载、打开接口 - 实现Android端文件下载到沙盒目录或公共Downloads目录,并支持通知栏进度显示 - 完善Android WebView组件,增加文件选择、拍照、下载拦截、H5双向通信能力 - 在iOS SDK中新增XuqmFileSDK模块,提供文件上传下载功能 - 实现iOS端WebView组件的文件下载拦截和原生文件选择器集成 - 更新文档说明Android和iOS SDK的文件操作API使用方法 - 重构iOS SDK项目结构,按功能拆分为多个独立模块便于集成 - 添加文件下载进度通知和完成后的文件打开功能
这个提交包含在:
父节点
25b80ce9e3
当前提交
5b34c60838
@ -54,6 +54,7 @@ let package = Package(
|
||||
),
|
||||
.target(
|
||||
name: "XuqmWebViewSDK",
|
||||
dependencies: ["XuqmFileSDK"],
|
||||
path: "Sources/XuqmWebViewSDK",
|
||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||
),
|
||||
|
||||
153
README.md
153
README.md
@ -6,27 +6,21 @@
|
||||
|
||||
```
|
||||
XuqmGroup-iOSSDK/
|
||||
├── Package.swift # SPM 包定义
|
||||
├── XuqmSDK.podspec # CocoaPods 发版描述
|
||||
├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述
|
||||
├── PUBLISH.md # 发版操作手册
|
||||
├── Sources/XuqmSDK/
|
||||
├── Core/
|
||||
│ ├── XuqmSDK.swift # 入口:init / setToken
|
||||
│ ├── SDKConfig.swift # 配置结构体
|
||||
│ ├── ApiClient.swift # HTTP 客户端(URLSession)
|
||||
│ └── TokenStore.swift # Keychain Token 存储
|
||||
├── IM/
|
||||
│ └── ImClient.swift # WebSocket IM 客户端
|
||||
├── Push/
|
||||
│ └── PushSDK.swift # APNs Token 注册
|
||||
└── Update/
|
||||
└── UpdateSDK.swift # 版本检查 / App 更新
|
||||
└── Sources/XuqmWebViewSDK/
|
||||
├── XWebViewBridge.swift # 页面配置 / 控制器桥接
|
||||
├── XWebViewPage.swift # 独立页面
|
||||
├── XWebViewTypes.swift # 配置 / 控制器协议
|
||||
└── XWebViewView.swift # 嵌入式组件
|
||||
├── Package.swift # SPM 包定义
|
||||
├── XuqmSDK.podspec # CocoaPods 发版描述
|
||||
├── XuqmWebViewSDK.podspec # WebView 独立模块发版描述
|
||||
├── PUBLISH.md # 发版操作手册
|
||||
├── Sources/XuqmCoreSDK/ # 核心:初始化、HTTP、Token
|
||||
├── Sources/XuqmImSDK/ # IM:WebSocket 实时通信
|
||||
├── Sources/XuqmPushSDK/ # 推送:APNs Token 注册
|
||||
├── Sources/XuqmUpdateSDK/ # 版本管理:检查更新
|
||||
├── Sources/XuqmLicenseSDK/ # 授权管理
|
||||
├── Sources/XuqmFileSDK/ # 文件上传、下载、本地打开
|
||||
└── Sources/XuqmWebViewSDK/ # WebView 嵌入式组件 / 独立页面
|
||||
├── XWebViewBridge.swift # 页面配置 / 控制器桥接
|
||||
├── XWebViewPage.swift # 独立页面
|
||||
├── XWebViewTypes.swift # 配置 / 控制器协议
|
||||
└── XWebViewView.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(推荐)
|
||||
|
||||
@ -12,12 +12,89 @@ public struct FileUploadResult: Codable, Sendable {
|
||||
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 {
|
||||
let config = await XuqmSDK.shared.requireConfig()
|
||||
_ = await XuqmSDK.shared.requireConfig()
|
||||
let tokenStore = await XuqmSDK.shared.tokenStore
|
||||
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 XuqmFileSDK
|
||||
|
||||
public struct XWebViewConfig: Sendable {
|
||||
public var url: String
|
||||
@ -7,6 +8,8 @@ public struct XWebViewConfig: Sendable {
|
||||
public var hideStatusBar: Bool
|
||||
public var userAgent: String?
|
||||
public var injectedJavaScript: String?
|
||||
/// Where intercepted WebView downloads are saved.
|
||||
public var downloadDestination: FileDownloadDestination
|
||||
public var onMessage: (@Sendable (String) -> Void)?
|
||||
|
||||
public init(
|
||||
@ -16,6 +19,7 @@ public struct XWebViewConfig: Sendable {
|
||||
hideStatusBar: Bool = false,
|
||||
userAgent: String? = nil,
|
||||
injectedJavaScript: String? = nil,
|
||||
downloadDestination: FileDownloadDestination = .sandbox,
|
||||
onMessage: (@Sendable (String) -> Void)? = nil
|
||||
) {
|
||||
self.url = url
|
||||
@ -24,6 +28,7 @@ public struct XWebViewConfig: Sendable {
|
||||
self.hideStatusBar = hideStatusBar
|
||||
self.userAgent = userAgent
|
||||
self.injectedJavaScript = injectedJavaScript
|
||||
self.downloadDestination = downloadDestination
|
||||
self.onMessage = onMessage
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import XuqmFileSDK
|
||||
|
||||
// JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native.
|
||||
private let kBridgeName = "ReactNativeWebView"
|
||||
@ -181,19 +182,97 @@ public struct XWebViewView: UIViewRepresentable {
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard let raw = message.body as? String else { return }
|
||||
// Filter internal __xwv messages; forward the rest to onMessage.
|
||||
if let data = raw.data(using: .utf8),
|
||||
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
|
||||
}
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
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
|
||||
}
|
||||
config.onMessage?(raw)
|
||||
}
|
||||
|
||||
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, *)
|
||||
public func webView(
|
||||
_ webView: WKWebView,
|
||||
@ -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
|
||||
|
||||
public struct XWebViewView: View {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户