From 0ce2f21307d64734f8d8e4e44e09adf1e277f7f4 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 5 Jun 2026 15:48:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E6=96=B0=E5=A2=9E=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E4=B8=8B=E8=BD=BD=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84WebView=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在Android SDK中新增FileSDK模块,提供统一的文件上传、下载、打开接口 - 实现Android端文件下载到沙盒目录或公共Downloads目录,并支持通知栏进度显示 - 完善Android WebView组件,增加文件选择、拍照、下载拦截、H5双向通信能力 - 在iOS SDK中新增XuqmFileSDK模块,提供文件上传下载功能 - 实现iOS端WebView组件的文件下载拦截和原生文件选择器集成 - 更新文档说明Android和iOS SDK的文件操作API使用方法 - 重构iOS SDK项目结构,按功能拆分为多个独立模块便于集成 - 添加文件下载进度通知和完成后的文件打开功能 --- README.md | 162 ++++++++++++++++++ .../main/java/com/xuqm/sdk/file/FileSDK.kt | 160 ++++++++++++++++- .../com/xuqm/sdk/webview/XWebViewTypes.kt | 6 + .../java/com/xuqm/sdk/webview/XWebViewView.kt | 117 ++++++++++++- 4 files changed, 431 insertions(+), 14 deletions(-) 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 }