소스 검색

webview 支持文件选择 <input type="file" />

cc12458 1 개월 전
부모
커밋
d43fcbb9d1

+ 9 - 0
library/browser/src/main/AndroidManifest.xml

@@ -12,6 +12,15 @@
       android:networkSecurityConfig="@xml/network_security_config"
       android:usesCleartextTraffic="true">
 
+    <provider
+        android:name="androidx.core.content.FileProvider"
+        android:authorities="${applicationId}.file.provider"
+        android:exported="false"
+        android:grantUriPermissions="true">
+      <meta-data
+          android:name="android.support.FILE_PROVIDER_PATHS"
+          android:resource="@xml/file_paths" />
+    </provider>
   </application>
 
   <queries>

+ 1 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebView.kt

@@ -33,6 +33,7 @@ fun WebView(
   }
 
   WebViewPermission(controller)
+  WebViewFileChooser(controller)
 
   AndroidView(
     factory = { context ->

+ 2 - 1
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewController.kt

@@ -1,6 +1,5 @@
 package com.hzliuzhi.applet.browser.webview
 
-import android.util.Log
 import android.webkit.PermissionRequest
 import android.webkit.WebView
 import androidx.compose.runtime.getValue
@@ -10,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.launch
 
+
 class WebViewController(
   private val coroutineScope: CoroutineScope,
 ) {
@@ -23,6 +23,7 @@ class WebViewController(
 
 
   val permissionRequest = MutableStateFlow<PermissionRequest?>(null)
+  val fileChooser = MutableStateFlow<FileChooserRequest?>(null)
 
   fun connected(webview: WebView, content: WebContent) {
     this.webview = webview

+ 171 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewFileChooser.kt

@@ -0,0 +1,171 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.content.Context
+import android.net.Uri
+import android.os.Environment
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient.FileChooserParams
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.FileProvider
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun WebViewFileChooser(controller: WebViewController) {
+  val context = LocalContext.current.applicationContext
+
+  val fileChooser by controller.fileChooser.collectAsState()
+  var showDialog by remember { mutableStateOf(false) }
+  var temporaryUri by remember { mutableStateOf<Uri?>(null) }
+
+  var file: File? = null
+
+
+  fun callback(values: Array<Uri>?) {
+    fileChooser?.callback?.onReceiveValue(values ?: emptyArray())
+    controller.fileChooser.value = null
+    file = null
+  }
+
+  fun callback(value: Uri?) = callback(if (value != null) arrayOf(value) else emptyArray())
+
+  fun callback() {
+    callback(emptyArray())
+    // 清理未用的临时文件
+    file?.also { deleteTempFileByUri(it) }
+    file = null
+    showDialog = false
+  }
+
+  val capturePictureLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.TakePicture()
+  ) { success ->
+    temporaryUri.takeIf { success }?.also { callback(it) } ?: callback()
+    temporaryUri = null
+  }
+
+  val selectPictureLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.GetContent(),
+    onResult = ::callback
+  )
+
+  val selectMultiPictureLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.GetMultipleContents()
+  ) { uris ->
+    callback(uris.takeIf { it.isNotEmpty() }?.toTypedArray())
+  }
+
+  // 单选 launcher
+  val singleLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.GetContent(),
+    onResult = ::callback
+  )
+
+  // 多选 launcher
+  val multiLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.OpenMultipleDocuments()
+  ) { uris ->
+    callback(uris.takeIf { it.isNotEmpty() }?.toTypedArray())
+  }
+
+  val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
+  fun capturePicture() {
+    file?.also { deleteTempFileByUri(it) }
+    if (cameraPermissionState.status.isGranted) {
+      temporaryUri = createImageUri(context) { file = it }.also { capturePictureLauncher.launch(it) }
+    } else {
+      cameraPermissionState.launchPermissionRequest()
+    }
+    showDialog = false
+  }
+
+  fun selectPicture() {
+    showDialog = false
+    if (fileChooser?.params?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
+      selectMultiPictureLauncher.launch("image/*")
+    } else {
+      selectPictureLauncher.launch("image/*")
+    }
+  }
+
+  LaunchedEffect(fileChooser) {
+    fileChooser?.let { request ->
+      val mimeTypes = request.params?.acceptTypes?.filter { it.isNotBlank() }?.toTypedArray()
+      val mimeType = when {
+        mimeTypes.isNullOrEmpty() -> "*/*"
+        mimeTypes.size == 1 -> mimeTypes[0]
+        else -> "*/*"
+      }
+
+      if (mimeType.startsWith("image/")) {
+        request.params?.isCaptureEnabled?.takeIf { it }?.also {
+          capturePicture()
+        } ?: run {
+          showDialog = true
+        }
+      } else if (request.params?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
+        multiLauncher.launch(mimeTypes?.takeIf { it.isNotEmpty() } ?: arrayOf("*/*"))
+      } else {
+        singleLauncher.launch(mimeType)
+      }
+    }
+  }
+
+  if (showDialog) {
+    AlertDialog(
+      onDismissRequest = ::callback,
+      title = { Text("选择操作") },
+      text = { Text("请选择要拍照还是从相册选择照片") },
+      confirmButton = {
+        TextButton(onClick = ::capturePicture) { Text("拍照") }
+      },
+      dismissButton = {
+        TextButton(onClick = ::selectPicture) { Text("选择照片") }
+      }
+    )
+  }
+
+  BackHandler(showDialog) {
+    callback()
+  }
+}
+
+// 创建临时图片 Uri
+private fun createImageUri(context: Context, onSave: ((File) -> Unit) = {}): Uri {
+  val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+  val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
+  val file = File.createTempFile("JPEG_${timeStamp}_", ".jpg", storageDir).also { onSave.invoke(it) }
+  return FileProvider.getUriForFile(context, "${context.packageName}.file.provider", file)
+}
+
+private fun deleteTempFileByUri(file: File): Boolean {
+  return try {
+    if (file.exists()) file.delete() else false
+  } catch (_: Exception) {
+    false
+  }
+}
+
+data class FileChooserRequest(
+  val callback: ValueCallback<Array<Uri>>,
+  val params: FileChooserParams?,
+)

+ 1 - 1
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewPermission.kt

@@ -21,7 +21,7 @@ fun WebViewPermission(
   onDenied: ((PermissionRequest) -> Unit)? = { it.deny() },
 ) {
   val request by controller.permissionRequest.collectAsState()
-  var permissions = remember(request) {
+  val permissions = remember(request) {
     request?.resources?.mapNotNull(::mapWebResourceToPermission) ?: emptyList()
   }
   var hasRequested by remember { mutableStateOf(false) }

+ 9 - 1
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebChromeClient.kt

@@ -1,8 +1,9 @@
 package com.hzliuzhi.applet.browser.webview
 
 import android.graphics.Bitmap
-import android.util.Log
+import android.net.Uri
 import android.webkit.PermissionRequest
+import android.webkit.ValueCallback
 import android.webkit.WebChromeClient
 import android.webkit.WebView
 import kotlinx.coroutines.flow.update
@@ -36,4 +37,11 @@ open class WrapperWebChromeClient : WebChromeClient() {
   override fun onPermissionRequest(request: PermissionRequest?) {
     controller.permissionRequest.value = request
   }
+
+  override fun onShowFileChooser(webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean {
+    return filePathCallback?.let {
+      controller.fileChooser.value = FileChooserRequest(filePathCallback, fileChooserParams)
+      true
+    } ?: false
+  }
 }

+ 4 - 0
library/browser/src/main/res/xml/file_paths.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+  <external-files-path name="images" path="Pictures" />
+</paths>