feat(sdk): 新增文件上传下载功能并完善WebView组件
- 在Android SDK中新增FileSDK模块,提供统一的文件上传、下载、打开接口 - 实现Android端文件下载到沙盒目录或公共Downloads目录,并支持通知栏进度显示 - 完善Android WebView组件,增加文件选择、拍照、下载拦截、H5双向通信能力 - 在iOS SDK中新增XuqmFileSDK模块,提供文件上传下载功能 - 实现iOS端WebView组件的文件下载拦截和原生文件选择器集成 - 更新文档说明Android和iOS SDK的文件操作API使用方法 - 重构iOS SDK项目结构,按功能拆分为多个独立模块便于集成 - 添加文件下载进度通知和完成后的文件打开功能
这个提交包含在:
父节点
a98dd8a708
当前提交
0ce2f21307
162
README.md
162
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
|
||||
<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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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>): 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<WebView?>(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.
|
||||
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<PermissionRequest?>(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
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户