feat(webview): 优化文件下载和图片选择功能
- 下载文件时在公共下载目录下创建应用名称子目录 - 实现支持多MIME类型的文件选择器合约 - 添加相机拍照功能并在图片选择时显示选项对话框 - 为Android 13+系统添加下载通知权限请求 - 重构文件选择逻辑以支持更灵活的MIME类型处理
这个提交包含在:
父节点
beb6b88029
当前提交
6514c27eaa
@ -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 -> {
|
||||||
|
val appName = context.applicationInfo.loadLabel(context.packageManager).toString()
|
||||||
|
File(
|
||||||
android.os.Environment.getExternalStoragePublicDirectory(
|
android.os.Environment.getExternalStoragePublicDirectory(
|
||||||
android.os.Environment.DIRECTORY_DOWNLOADS
|
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 {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户