feat(webview): 优化文件下载和图片选择功能

- 下载文件时在公共下载目录下创建应用名称子目录
- 实现支持多MIME类型的文件选择器合约
- 添加相机拍照功能并在图片选择时显示选项对话框
- 为Android 13+系统添加下载通知权限请求
- 重构文件选择逻辑以支持更灵活的MIME类型处理
这个提交包含在:
XuqmGroup 2026-06-05 16:15:50 +08:00
父节点 beb6b88029
当前提交 6514c27eaa
共有 3 个文件被更改,包括 131 次插入49 次删除

查看文件

@ -162,9 +162,13 @@ object FileSDK {
?: "download.bin" ?: "download.bin"
val baseDir = when (destination) { val baseDir = when (destination) {
FileDownloadDestination.PublicDownloads -> FileDownloadDestination.PublicDownloads -> {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val appName = context.applicationInfo.loadLabel(context.packageManager).toString()
.apply { mkdirs() } File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
appName,
).apply { mkdirs() }
}
FileDownloadDestination.Sandbox -> { FileDownloadDestination.Sandbox -> {
if (directoryName.isNullOrBlank()) { if (directoryName.isNullOrBlank()) {
context.getExternalFilesDir(null) ?: context.filesDir context.getExternalFilesDir(null) ?: context.filesDir
@ -243,8 +247,12 @@ object FileSDK {
val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT) val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
val baseDir = when (destination) { val baseDir = when (destination) {
FileDownloadDestination.PublicDownloads -> { FileDownloadDestination.PublicDownloads -> {
android.os.Environment.getExternalStoragePublicDirectory( val appName = context.applicationInfo.loadLabel(context.packageManager).toString()
android.os.Environment.DIRECTORY_DOWNLOADS File(
android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS
),
appName,
).apply { mkdirs() } ).apply { mkdirs() }
} }
FileDownloadDestination.Sandbox -> { FileDownloadDestination.Sandbox -> {

查看文件

@ -54,9 +54,7 @@ class XWebViewActivity : ComponentActivity() {
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null) cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
} }
private val pickContentLauncher = registerForActivityResult( private val pickContentLauncher = registerForActivityResult(GetContentWithMimeTypes()) { uri ->
ActivityResultContracts.GetContent()
) { uri ->
val cb = pendingFileCallback val cb = pendingFileCallback
pendingFileCallback = null pendingFileCallback = null
cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null) cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null)
@ -83,6 +81,23 @@ class XWebViewActivity : ComponentActivity() {
} }
} }
private fun launchCamera() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", cacheDir)
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", imageFile)
pendingCameraUri = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null
}
} else {
shouldLaunchCamera = true
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -136,26 +151,25 @@ class XWebViewActivity : ComponentActivity() {
pendingFileCallback?.onReceiveValue(null) pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = filePathCallback pendingFileCallback = filePathCallback
val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes)
val isCameraCapture = fileChooserParams.isCaptureEnabled && val isCameraCapture = fileChooserParams.isCaptureEnabled &&
fileChooserParams.acceptTypes.any { it.contains("image") } fileChooserParams.acceptTypes.any { it.contains("image") }
val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" }
if (isCameraCapture) { when {
if (ContextCompat.checkSelfPermission(this@XWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { isCameraCapture -> launchCamera()
runCatching { isImageOnly -> android.app.AlertDialog.Builder(this@XWebViewActivity)
val imageFile = File.createTempFile("cam_", ".jpg", cacheDir) .setTitle("选择图片")
val uri = FileProvider.getUriForFile(this@XWebViewActivity, "${packageName}.fileprovider", imageFile) .setItems(arrayOf("从相册选择", "拍照")) { _, which ->
pendingCameraUri = uri if (which == 0) pickContentLauncher.launch(arrayOf("image/*"))
takePictureLauncher.launch(uri) else launchCamera()
}.onFailure { }
.setOnCancelListener {
pendingFileCallback?.onReceiveValue(null) pendingFileCallback?.onReceiveValue(null)
pendingFileCallback = null pendingFileCallback = null
} }
} else { .show()
shouldLaunchCamera = true else -> pickContentLauncher.launch(acceptMimes)
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes))
} }
return true return true
} }

查看文件

@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
@ -20,6 +21,7 @@ import android.webkit.WebViewClient
import android.webkit.WebView import android.webkit.WebView
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -31,6 +33,9 @@ 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.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@ -42,12 +47,12 @@ import org.json.JSONObject
import java.io.File import java.io.File
import java.util.Locale import java.util.Locale
// Maps HTML accept types (MIME or dot-prefixed extensions) to a single MIME for ACTION_GET_CONTENT. // Maps HTML accept types (MIME or dot-prefixed extensions) to an array of MIME strings for ACTION_GET_CONTENT.
// Returns "*/*" when types are empty, mixed, or cannot be resolved. // Returns ["*/*"] when types are empty or cannot be resolved.
internal fun resolvePickerMimeType(acceptTypes: Array<String>): String { internal fun resolvePickerMimeTypes(acceptTypes: Array<String>): Array<String> {
val nonBlank = acceptTypes.filter { it.isNotBlank() } val nonBlank = acceptTypes.filter { it.isNotBlank() }
if (nonBlank.isEmpty()) return "*/*" if (nonBlank.isEmpty()) return arrayOf("*/*")
val resolved = nonBlank.map { type -> return nonBlank.map { type ->
if (type.startsWith(".")) { if (type.startsWith(".")) {
MimeTypeMap.getSingleton() MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT)) .getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT))
@ -55,8 +60,24 @@ internal fun resolvePickerMimeType(acceptTypes: Array<String>): String {
} else { } else {
type type
} }
}.distinct() }.distinct().toTypedArray()
return if (resolved.size == 1) resolved[0] else "*/*" }
// ACTION_GET_CONTENT contract supporting multiple MIME types via EXTRA_MIME_TYPES.
internal class GetContentWithMimeTypes : ActivityResultContract<Array<String>, Uri?>() {
override fun createIntent(context: Context, input: Array<String>): Intent =
Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
if (input.size == 1) {
type = input[0]
} else {
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, input)
}
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? =
intent?.takeIf { resultCode == android.app.Activity.RESULT_OK }?.data
} }
// JS injected into every page to bridge dialog APIs and download interception. // JS injected into every page to bridge dialog APIs and download interception.
@ -187,6 +208,11 @@ fun XWebViewView(
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() val coroutineScope = rememberCoroutineScope()
var showImageSourceDialog by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* download continues regardless; notification skipped if denied */ }
// 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) }
@ -206,6 +232,12 @@ fun XWebViewView(
"download" -> { "download" -> {
val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler
val filename = payload.optString("filename").takeIf { it.isNotBlank() } val filename = payload.optString("filename").takeIf { it.isNotBlank() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
config.downloadNotificationTitle != null &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
runCatching { runCatching {
FileSDK.download( FileSDK.download(
@ -279,9 +311,7 @@ fun XWebViewView(
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null) cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
} }
val pickContentLauncher = rememberLauncherForActivityResult( val pickContentLauncher = rememberLauncherForActivityResult(GetContentWithMimeTypes()) { uri ->
ActivityResultContracts.GetContent()
) { uri ->
val cb = pendingFileCallback.value val cb = pendingFileCallback.value
pendingFileCallback.value = null pendingFileCallback.value = null
cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null) cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null)
@ -319,6 +349,23 @@ fun XWebViewView(
} }
} }
val launchCamera: () -> Unit = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile)
pendingCameraUri.value = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
}
} else {
shouldLaunchCamera.value = true
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
AndroidView( AndroidView(
modifier = modifier, modifier = modifier,
factory = { ctx -> factory = { ctx ->
@ -376,26 +423,15 @@ fun XWebViewView(
pendingFileCallback.value?.onReceiveValue(null) pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = filePathCallback pendingFileCallback.value = filePathCallback
val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes)
val isCameraCapture = fileChooserParams.isCaptureEnabled && val isCameraCapture = fileChooserParams.isCaptureEnabled &&
fileChooserParams.acceptTypes.any { it.contains("image") } fileChooserParams.acceptTypes.any { it.contains("image") }
val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" }
if (isCameraCapture) { when {
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { isCameraCapture -> launchCamera()
runCatching { isImageOnly -> showImageSourceDialog = true
val imageFile = File.createTempFile("cam_", ".jpg", ctx.cacheDir) else -> pickContentLauncher.launch(acceptMimes)
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", imageFile)
pendingCameraUri.value = uri
takePictureLauncher.launch(uri)
}.onFailure {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
}
} else {
shouldLaunchCamera.value = true
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes))
} }
return true return true
} }
@ -423,6 +459,30 @@ fun XWebViewView(
}, },
) )
if (showImageSourceDialog) {
AlertDialog(
onDismissRequest = {
showImageSourceDialog = false
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
},
title = { Text("选择图片") },
text = null,
confirmButton = {
TextButton(onClick = {
showImageSourceDialog = false
pickContentLauncher.launch(arrayOf("image/*"))
}) { Text("从相册选择") }
},
dismissButton = {
TextButton(onClick = {
showImageSourceDialog = false
launchCamera()
}) { Text("拍照") }
},
)
}
SideEffect { SideEffect {
val view = webView ?: return@SideEffect val view = webView ?: return@SideEffect
setXWebViewController(object : XWebViewController { setXWebViewController(object : XWebViewController {