diff --git a/README.md b/README.md
index 0c4ca1b..0cec9e0 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,77 @@ XuqmSDK.tokenStore.clear()
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
+
+
+
+```
+
---
## 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 内 `` 和 `` 均已内置处理:
+
+- `accept="image/*"` + `capture` → 调起系统相机,自动申请 `CAMERA` 权限
+- `accept=".docx,.xlsx"` 等扩展名格式 → 自动映射为正确的 MIME 类型后调起文件选择器
+- `getUserMedia()` WebRTC 摄像头 → 自动请求 `CAMERA` 权限后授权
+
+### 下载拦截
+
+注入的 JS 自动拦截以下两种场景,下载完成后调用 `FileSDK.openFile()` 打开文件:
+
+- 带 `download` 属性的 `` 标签,或链接以可下载扩展名(`.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
diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt
index 0d8d392..a62a5b2 100644
--- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt
+++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt
@@ -1,7 +1,17 @@
package com.xuqm.sdk.file
+import android.app.NotificationChannel
+import android.app.NotificationManager
import android.content.Context
+import android.content.Intent
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.network.ApiClient
import kotlinx.coroutines.Dispatchers
@@ -14,6 +24,13 @@ import retrofit2.http.POST
import retrofit2.http.Part
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(
val url: String,
val thumbnailUrl: String? = null,
@@ -121,15 +138,144 @@ object FileSDK {
fileName: String? = null,
directoryName: String? = null,
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) {
- val dir = if (directoryName.isNullOrBlank()) {
- context.getExternalFilesDir(null) ?: context.filesDir
- } else {
- File(context.getExternalFilesDir(null) ?: context.filesDir, directoryName).apply { mkdirs() }
+ val resolvedName = fileName?.takeIf { it.isNotBlank() }
+ ?: downloadUrl.substringAfterLast('/').substringBefore('?').takeIf { it.isNotBlank() }
+ ?: "download.bin"
+
+ 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
}
+
+ 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
+ }
}
diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt
index 9458f50..e46bfe4 100644
--- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt
+++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt
@@ -1,5 +1,7 @@
package com.xuqm.sdk.webview
+import com.xuqm.sdk.file.FileDownloadDestination
+
data class XWebViewConfig(
val url: String = "",
val title: String = "",
@@ -9,6 +11,10 @@ data class XWebViewConfig(
val injectedJavaScript: String? = null,
val jsBridgeName: String = "XWebViewBridge",
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,
)
diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
index d6787ef..4af5c7b 100644
--- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
+++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
@@ -8,8 +8,10 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
+import android.util.Base64
import android.view.ViewGroup
import android.webkit.JavascriptInterface
+import android.webkit.MimeTypeMap
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
@@ -25,15 +27,41 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
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.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 {
+ 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.
// Uses addJavascriptInterface(jsBridgeName) for the JS→Native channel.
internal fun buildDialogOverrideJs(bridgeName: String) = """
@@ -125,18 +153,33 @@ internal fun openExternalScheme(context: Context, uri: Uri): Boolean {
}.getOrDefault(false)
}
-// Routes window.ReactNativeWebView.postMessage() calls to [onMessage].
-// @JavascriptInterface methods are called on a background thread; we post to main.
+// Routes JS messages to onMessage (business) or onXwvMessage (internal __xwv events).
+// @JavascriptInterface is called on a background thread; all delivery is posted to main.
internal class XWebViewJsBridge(
private val mainHandler: Handler,
private val onMessage: () -> ((String) -> Unit)?,
+ private val onXwvMessage: () -> ((String, JSONObject) -> Unit)?,
) {
@JavascriptInterface
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")
@Composable
fun XWebViewView(
@@ -146,6 +189,7 @@ fun XWebViewView(
val context = LocalContext.current
var webView by remember { mutableStateOf(null) }
var currentUrl by remember { mutableStateOf(config.url.ifBlank { null }) }
+ val coroutineScope = rememberCoroutineScope()
// Keep onMessage ref up-to-date across recompositions without recreating the bridge object.
val onMessageRef = remember { mutableStateOf(config.onMessage) }
@@ -153,6 +197,67 @@ fun XWebViewView(
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
val pendingWebRtcRequest = remember { mutableStateOf(null) }
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
@@ -230,7 +335,7 @@ fun XWebViewView(
// JS → Native bridge. Must be added before loadUrl.
addJavascriptInterface(
- XWebViewJsBridge(mainHandler) { onMessageRef.value },
+ XWebViewJsBridge(mainHandler, { onMessageRef.value }, { xwvMessageRef.value }),
config.jsBridgeName,
)
@@ -293,9 +398,7 @@ fun XWebViewView(
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
- val mimeType = fileChooserParams.acceptTypes
- .firstOrNull { it.isNotBlank() } ?: "image/*"
- pickContentLauncher.launch(mimeType)
+ pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes))
}
return true
}