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
父节点 a98dd8a708
当前提交 0ce2f21307
共有 4 个文件被更改,包括 431 次插入14 次删除

162
README.md
查看文件

@ -158,6 +158,77 @@ XuqmSDK.tokenStore.clear()
val service = RetrofitFactory.create(MyApiService::class.java) val service = RetrofitFactory.create(MyApiService::class.java)
``` ```
### FileSDK
文件上传、下载、打开的统一入口,位于 `com.xuqm.sdk.file`
#### 上传
```kotlin
// 从 Uri文件选择器返回值上传,自动解析文件名和 MIME 类型
val result: FileUploadResult = FileSDK.upload(
context = context,
uri = uri,
onProgress = { progress -> /* 0–100 */ },
)
// 直接上传字节数组(如相机拍照后的 ByteArray
val result = FileSDK.uploadBytes(
fileName = "photo.jpg",
mimeType = "image/jpeg",
bytes = byteArray,
onProgress = { progress -> },
)
// 上传 File 对象
val result = FileSDK.upload(file = file)
```
`FileUploadResult` 字段:`url`、`thumbnailUrl`、`hash`、`size`、`originalName`、`mimeType`、`ext`
#### 下载
```kotlin
// 下载存储目标
sealed class FileDownloadDestination {
data object Sandbox : FileDownloadDestination() // 应用私有目录(无需权限)
data object PublicDownloads : FileDownloadDestination() // 系统 Downloads 文件夹
}
// 下载到指定目标,支持通知栏进度
val file: File = FileSDK.download(
context = context,
downloadUrl = "https://example.com/report.pdf",
fileName = "report.pdf", // 可选,默认从 URL 推断
destination = FileDownloadDestination.PublicDownloads,
notificationTitle = "正在下载", // 非 null 时显示通知栏进度条
onProgress = { progress -> /* 0–100,同步进度到 H5 或 UI */ },
)
```
> **通知栏进度**:设置 `notificationTitle` 后,下载过程中通知栏会显示带进度条的持续通知,完成后自动切换为完成图标。需在 `AndroidManifest.xml` 中声明 `POST_NOTIFICATIONS` 权限Android 13+)。
#### 打开文件
```kotlin
// 用系统应用打开本地文件(通过 FileProvider + ACTION_VIEW
FileSDK.openFile(context, file)
```
需在 `AndroidManifest.xml` 中配置 `FileProvider`
```xml
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
```
--- ---
## sdk-im ## sdk-im
@ -299,6 +370,97 @@ AndroidManifest 中已配置 `@xml/file_paths``external-files-path`)。
--- ---
## sdk-webview
`XWebViewView` 是基于 `android.webkit.WebView` 封装的 Jetpack Compose 组件,内置文件选择、拍照、下载拦截和 JS 通信能力。
### XWebViewConfig 完整参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `url` | String | `""` | 初始加载地址 |
| `title` | String | `""` | 页面标题(独立页面模式使用) |
| `hideToolbar` | Boolean | `false` | 隐藏独立页面顶栏 |
| `hideStatusBar` | Boolean | `false` | 隐藏状态栏 |
| `userAgent` | String? | `null` | 自定义 User-Agent |
| `injectedJavaScript` | String? | `null` | 页面加载后注入的额外 JS |
| `jsBridgeName` | String | `"XWebViewBridge"` | JS 桥接对象名 |
| `debugEnabled` | Boolean | `false` | 开启 WebView 远程调试 |
| `downloadDestination` | FileDownloadDestination | `Sandbox` | 下载文件存储目标 |
| `downloadNotificationTitle` | String? | `null` | 非 null 时通知栏显示下载进度 |
| `onMessage` | `(String) -> Unit`? | `null` | H5 发送消息的回调 |
### 文件选择与拍照
WebView 内 `<input type="file">``<input type="file" capture>` 均已内置处理:
- `accept="image/*"` + `capture` → 调起系统相机,自动申请 `CAMERA` 权限
- `accept=".docx,.xlsx"` 等扩展名格式 → 自动映射为正确的 MIME 类型后调起文件选择器
- `getUserMedia()` WebRTC 摄像头 → 自动请求 `CAMERA` 权限后授权
### 下载拦截
注入的 JS 自动拦截以下两种场景,下载完成后调用 `FileSDK.openFile()` 打开文件:
- 带 `download` 属性的 `<a>` 标签,或链接以可下载扩展名(`.pdf`、`.zip`、`.docx` 等)结尾
- Blob URL自动转 base64 后传给 native 处理)
```kotlin
XWebViewView(
config = XWebViewConfig(
url = "https://example.com",
downloadDestination = FileDownloadDestination.PublicDownloads, // 存入系统 Downloads
downloadNotificationTitle = "正在下载", // 通知栏进度
)
)
```
### H5 监听下载进度
H5 页面可通过 `window.addEventListener` 接收下载进度和完成事件:
```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.XWebViewBridge.postMessage(JSON.stringify({ type: 'login', token: '...' }))
```
```kotlin
XWebViewConfig(
onMessage = { raw ->
val json = JSONObject(raw)
when (json.optString("type")) {
"login" -> { /* 处理登录 */ }
}
}
)
```
```kotlin
// Native 发消息给 H5
val controller = getXWebViewController()
controller?.postMessageToWeb("window.dispatchEvent(new CustomEvent('nativeMsg', { detail: { key: 'value' } }))")
```
---
## 发版 ## 发版
```bash ```bash

查看文件

@ -1,7 +1,17 @@
package com.xuqm.sdk.file package com.xuqm.sdk.file
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.xuqm.sdk.core.ServiceEndpointRegistry import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -14,6 +24,13 @@ import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import java.io.File import java.io.File
sealed class FileDownloadDestination {
/** App-private external files dir (no special permission needed). */
data object Sandbox : FileDownloadDestination()
/** System Downloads folder — visible in Files / Downloads app. */
data object PublicDownloads : FileDownloadDestination()
}
data class FileUploadResult( data class FileUploadResult(
val url: String, val url: String,
val thumbnailUrl: String? = null, val thumbnailUrl: String? = null,
@ -121,15 +138,144 @@ object FileSDK {
fileName: String? = null, fileName: String? = null,
directoryName: String? = null, directoryName: String? = null,
onProgress: (Int) -> Unit = {}, onProgress: (Int) -> Unit = {},
): File = download(
context = context,
downloadUrl = downloadUrl,
fileName = fileName,
destination = FileDownloadDestination.Sandbox,
directoryName = directoryName,
onProgress = onProgress,
)
suspend fun download(
context: Context,
downloadUrl: String,
fileName: String? = null,
destination: FileDownloadDestination = FileDownloadDestination.Sandbox,
directoryName: String? = null,
/** When non-null, shows a progress notification in the status bar with this title. */
notificationTitle: String? = null,
onProgress: (Int) -> Unit = {},
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
val dir = if (directoryName.isNullOrBlank()) { val resolvedName = fileName?.takeIf { it.isNotBlank() }
context.getExternalFilesDir(null) ?: context.filesDir ?: downloadUrl.substringAfterLast('/').substringBefore('?').takeIf { it.isNotBlank() }
} else { ?: "download.bin"
File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() }
val baseDir = when (destination) {
FileDownloadDestination.PublicDownloads ->
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
.apply { mkdirs() }
FileDownloadDestination.Sandbox -> {
if (directoryName.isNullOrBlank()) {
context.getExternalFilesDir(null) ?: context.filesDir
} else {
File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() }
}
}
}
val target = uniqueFile(baseDir, resolvedName)
val notifId = if (notificationTitle != null) {
ensureDownloadNotificationChannel(context)
System.currentTimeMillis().toInt()
} else null
val notifBuilder = notifId?.let { id ->
NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle(notificationTitle)
.setContentText(resolvedName)
.setOngoing(true)
.setProgress(100, 0, false)
.also { NotificationManagerCompat.from(context).notify(id, it.build()) }
}
try {
FileTransfer.downloadToFile(downloadUrl, target) { progress ->
notifBuilder?.let { builder ->
builder.setProgress(100, progress, false)
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
NotificationManagerCompat.from(context).notify(notifId!!, builder.build())
}
}
onProgress(progress)
}
} finally {
notifId?.let { id ->
notifBuilder
?.setOngoing(false)
?.setProgress(0, 0, false)
?.setSmallIcon(android.R.drawable.stat_sys_download_done)
?.setContentText(context.getString(android.R.string.ok))
?.also {
if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
NotificationManagerCompat.from(context).notify(id, it.build())
}
}
}
} }
val resolvedName = fileName?.takeIf { it.isNotBlank() } ?: downloadUrl.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "download.bin"
val target = File(dir, resolvedName)
FileTransfer.downloadToFile(downloadUrl, target, onProgress)
target target
} }
private fun ensureDownloadNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
DOWNLOAD_CHANNEL_ID,
"Downloads",
NotificationManager.IMPORTANCE_LOW,
).apply { description = "File download progress" }
context.getSystemService(NotificationManager::class.java)
?.createNotificationChannel(channel)
}
}
private const val DOWNLOAD_CHANNEL_ID = "xuqm_downloads"
fun saveBlobDownload(
context: Context,
base64Data: String,
fileName: String,
destination: FileDownloadDestination = FileDownloadDestination.Sandbox,
): File {
val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
val baseDir = when (destination) {
FileDownloadDestination.PublicDownloads -> {
android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS
).apply { mkdirs() }
}
FileDownloadDestination.Sandbox -> {
context.getExternalFilesDir(null) ?: context.filesDir
}
}
val target = uniqueFile(baseDir, fileName.takeIf { it.isNotBlank() } ?: "download.bin")
target.writeBytes(bytes)
return target
}
fun openFile(context: Context, file: File) {
val mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(file.extension.lowercase())
?: "application/octet-stream"
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(Intent.createChooser(intent, null).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
private fun uniqueFile(dir: File, name: String): File {
val base = name.substringBeforeLast('.', name)
val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
var candidate = File(dir, name)
var index = 1
while (candidate.exists()) {
candidate = File(dir, "$base($index)$ext")
index++
}
return candidate
}
} }

查看文件

@ -1,5 +1,7 @@
package com.xuqm.sdk.webview package com.xuqm.sdk.webview
import com.xuqm.sdk.file.FileDownloadDestination
data class XWebViewConfig( data class XWebViewConfig(
val url: String = "", val url: String = "",
val title: String = "", val title: String = "",
@ -9,6 +11,10 @@ data class XWebViewConfig(
val injectedJavaScript: String? = null, val injectedJavaScript: String? = null,
val jsBridgeName: String = "XWebViewBridge", val jsBridgeName: String = "XWebViewBridge",
val debugEnabled: Boolean = false, val debugEnabled: Boolean = false,
/** Where intercepted WebView downloads are saved. */
val downloadDestination: FileDownloadDestination = FileDownloadDestination.Sandbox,
/** When non-null, shows a status-bar progress notification with this title while downloading. */
val downloadNotificationTitle: String? = null,
val onMessage: ((String) -> Unit)? = null, val onMessage: ((String) -> Unit)? = null,
) )

查看文件

@ -8,8 +8,10 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Base64
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.MimeTypeMap
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import android.webkit.ValueCallback import android.webkit.ValueCallback
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
@ -25,15 +27,41 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.xuqm.sdk.file.FileSDK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File import java.io.File
import java.util.Locale import java.util.Locale
/**
* Converts an array of HTML accept types (MIME types or dot-prefixed extensions like ".docx")
* into a single MIME type string suitable for ACTION_GET_CONTENT.
* Falls back to "*\/*" when types are mixed, unknown, or empty.
*/
internal fun resolvePickerMimeType(acceptTypes: Array<String>): String {
val nonBlank = acceptTypes.filter { it.isNotBlank() }
if (nonBlank.isEmpty()) return "*/*"
val resolved = nonBlank.map { type ->
if (type.startsWith(".")) {
MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT))
?: "*/*"
} else {
type
}
}.distinct()
return if (resolved.size == 1) resolved[0] else "*/*"
}
// JS injected into every page to bridge dialog APIs and download interception. // JS injected into every page to bridge dialog APIs and download interception.
// Uses addJavascriptInterface(jsBridgeName) for the JS→Native channel. // Uses addJavascriptInterface(jsBridgeName) for the JS→Native channel.
internal fun buildDialogOverrideJs(bridgeName: String) = """ internal fun buildDialogOverrideJs(bridgeName: String) = """
@ -125,18 +153,33 @@ internal fun openExternalScheme(context: Context, uri: Uri): Boolean {
}.getOrDefault(false) }.getOrDefault(false)
} }
// Routes window.ReactNativeWebView.postMessage() calls to [onMessage]. // Routes JS messages to onMessage (business) or onXwvMessage (internal __xwv events).
// @JavascriptInterface methods are called on a background thread; we post to main. // @JavascriptInterface is called on a background thread; all delivery is posted to main.
internal class XWebViewJsBridge( internal class XWebViewJsBridge(
private val mainHandler: Handler, private val mainHandler: Handler,
private val onMessage: () -> ((String) -> Unit)?, private val onMessage: () -> ((String) -> Unit)?,
private val onXwvMessage: () -> ((String, JSONObject) -> Unit)?,
) { ) {
@JavascriptInterface @JavascriptInterface
fun postMessage(data: String) { fun postMessage(data: String) {
mainHandler.post { onMessage()?.invoke(data) } mainHandler.post {
val json = runCatching { JSONObject(data) }.getOrNull()
val xwv = json?.optString("__xwv")?.takeIf { it.isNotEmpty() }
if (xwv != null && json != null) {
onXwvMessage()?.invoke(xwv, json)
} else {
onMessage()?.invoke(data)
}
}
} }
} }
/** Escapes a string for safe inline use inside a JS string literal. */
internal fun String.escapeJs(): String = replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@Composable @Composable
fun XWebViewView( fun XWebViewView(
@ -146,6 +189,7 @@ fun XWebViewView(
val context = LocalContext.current val context = LocalContext.current
var webView by remember { mutableStateOf<WebView?>(null) } var webView by remember { mutableStateOf<WebView?>(null) }
var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) } var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) }
val coroutineScope = rememberCoroutineScope()
// Keep onMessage ref up-to-date across recompositions without recreating the bridge object. // Keep onMessage ref up-to-date across recompositions without recreating the bridge object.
val onMessageRef = remember { mutableStateOf(config.onMessage) } val onMessageRef = remember { mutableStateOf(config.onMessage) }
@ -153,6 +197,67 @@ fun XWebViewView(
val mainHandler = remember { Handler(Looper.getMainLooper()) } val mainHandler = remember { Handler(Looper.getMainLooper()) }
// Injects a CustomEvent into the current page to report download progress/completion.
fun dispatchDownloadEvent(eventName: String, url: String, extra: String = "") {
val js = "window.dispatchEvent(new CustomEvent('$eventName',{detail:{url:'${url.escapeJs()}'$extra}}));"
webView?.evaluateJavascript(js, null)
}
// Handles __xwv: 'download' / 'blobdownload' messages from the injected JS.
val xwvMessageHandler: (String, JSONObject) -> Unit = handler@{ type, payload ->
when (type) {
"download" -> {
val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler
val filename = payload.optString("filename").takeIf { it.isNotBlank() }
coroutineScope.launch(Dispatchers.IO) {
runCatching {
FileSDK.download(
context = context,
downloadUrl = url,
fileName = filename,
destination = config.downloadDestination,
notificationTitle = config.downloadNotificationTitle,
) { progress ->
mainHandler.post {
dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
}
}
}.onSuccess { file ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
FileSDK.openFile(context, file)
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
"blobdownload" -> {
val url = payload.optString("url")
val filename = payload.optString("filename").takeIf { it.isNotBlank() } ?: "download.bin"
val b64 = payload.optString("data").takeIf { it.isNotBlank() } ?: return@handler
coroutineScope.launch(Dispatchers.IO) {
runCatching {
FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
}.onSuccess { file ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
FileSDK.openFile(context, file)
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
}
}
val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) }
SideEffect { xwvMessageRef.value = xwvMessageHandler }
// WebRTC getUserMedia() camera permission // WebRTC getUserMedia() camera permission
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) } val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
val webRtcPermissionLauncher = rememberLauncherForActivityResult( val webRtcPermissionLauncher = rememberLauncherForActivityResult(
@ -230,7 +335,7 @@ fun XWebViewView(
// JS → Native bridge. Must be added before loadUrl. // JS → Native bridge. Must be added before loadUrl.
addJavascriptInterface( addJavascriptInterface(
XWebViewJsBridge(mainHandler) { onMessageRef.value }, XWebViewJsBridge(mainHandler, { onMessageRef.value }, { xwvMessageRef.value }),
config.jsBridgeName, config.jsBridgeName,
) )
@ -293,9 +398,7 @@ fun XWebViewView(
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
} }
} else { } else {
val mimeType = fileChooserParams.acceptTypes pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes))
.firstOrNull { it.isNotBlank() } ?: "image/*"
pickContentLauncher.launch(mimeType)
} }
return true return true
} }