refactor(webview): 将 WebView 活动重构为 Compose 组件
- 移除传统 View 系统实现,采用 Jetpack Compose 架构 - 创建 XWebViewScreen 组件替代原有的 Activity 布局 - 集成 Material Design 3 主题支持 - 保留 WebView 的核心功能和配置选项 - 添加导航状态回调接口支持 - 简化窗口边距和系统栏适配逻辑 - 优化组件生命周期管理和资源释放
这个提交包含在:
父节点
2a9b1c0f36
当前提交
0efb1b6f0f
@ -1,235 +1,22 @@
|
|||||||
package com.xuqm.sdk.webview
|
package com.xuqm.sdk.webview
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.webkit.PermissionRequest
|
|
||||||
import android.webkit.ValueCallback
|
|
||||||
import android.webkit.WebChromeClient
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.webkit.WebViewClient
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.compose.setContent
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class XWebViewActivity : ComponentActivity() {
|
class XWebViewActivity : ComponentActivity() {
|
||||||
|
|
||||||
private lateinit var webView: WebView
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
private var pendingWebRtcRequest: PermissionRequest? = null
|
|
||||||
private var pendingFileCallback: ValueCallback<Array<Uri>>? = null
|
|
||||||
private var pendingCameraUri: Uri? = null
|
|
||||||
private var shouldLaunchCamera = false
|
|
||||||
private var currentUrl: String? = null
|
|
||||||
|
|
||||||
private val webRtcPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { granted ->
|
|
||||||
val req = pendingWebRtcRequest
|
|
||||||
pendingWebRtcRequest = null
|
|
||||||
if (granted) req?.grant(req.resources) else req?.deny()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val takePictureLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.TakePicture()
|
|
||||||
) { success ->
|
|
||||||
val cb = pendingFileCallback
|
|
||||||
val uri = pendingCameraUri
|
|
||||||
pendingFileCallback = null
|
|
||||||
pendingCameraUri = null
|
|
||||||
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val pickContentLauncher = registerForActivityResult(GetContentWithMimeTypes()) { uri ->
|
|
||||||
val cb = pendingFileCallback
|
|
||||||
pendingFileCallback = null
|
|
||||||
cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val fileCameraPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { granted ->
|
|
||||||
val launch = shouldLaunchCamera
|
|
||||||
shouldLaunchCamera = false
|
|
||||||
if (granted && launch) {
|
|
||||||
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 {
|
|
||||||
pendingFileCallback?.onReceiveValue(null)
|
|
||||||
pendingFileCallback = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
setContent {
|
||||||
val config = getXWebViewConfig()
|
MaterialTheme {
|
||||||
|
XWebViewScreen(
|
||||||
WebView.setWebContentsDebuggingEnabled(config.debugEnabled)
|
config = getXWebViewConfig(),
|
||||||
val container = FrameLayout(this)
|
onBack = { finish() },
|
||||||
webView = WebView(this).apply {
|
|
||||||
settings.javaScriptEnabled = true
|
|
||||||
settings.domStorageEnabled = true
|
|
||||||
settings.useWideViewPort = true
|
|
||||||
settings.loadWithOverviewMode = true
|
|
||||||
config.userAgent?.let { settings.userAgentString = it }
|
|
||||||
|
|
||||||
addJavascriptInterface(
|
|
||||||
XWebViewJsBridge(mainHandler, { config.onMessage }, { null }),
|
|
||||||
config.jsBridgeName,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
|
||||||
currentUrl = url ?: view?.url
|
|
||||||
super.onPageFinished(view, url)
|
|
||||||
view?.evaluateJavascript(buildInjectedJs(config), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
|
|
||||||
}
|
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
|
||||||
override fun onPermissionRequest(request: PermissionRequest) {
|
|
||||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) {
|
|
||||||
if (ContextCompat.checkSelfPermission(this@XWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
request.grant(request.resources)
|
|
||||||
} else {
|
|
||||||
pendingWebRtcRequest = request
|
|
||||||
webRtcPermissionLauncher.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
request.deny()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShowFileChooser(
|
|
||||||
webView: WebView?,
|
|
||||||
filePathCallback: ValueCallback<Array<Uri>>,
|
|
||||||
fileChooserParams: FileChooserParams,
|
|
||||||
): Boolean {
|
|
||||||
pendingFileCallback?.onReceiveValue(null)
|
|
||||||
pendingFileCallback = filePathCallback
|
|
||||||
|
|
||||||
val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes)
|
|
||||||
val isCameraCapture = fileChooserParams.isCaptureEnabled &&
|
|
||||||
fileChooserParams.acceptTypes.any { it.contains("image") }
|
|
||||||
val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" }
|
|
||||||
|
|
||||||
when {
|
|
||||||
isCameraCapture -> launchCamera()
|
|
||||||
isImageOnly -> android.app.AlertDialog.Builder(this@XWebViewActivity)
|
|
||||||
.setTitle("选择图片")
|
|
||||||
.setItems(arrayOf("从相册选择", "拍照")) { _, which ->
|
|
||||||
if (which == 0) pickContentLauncher.launch(arrayOf("image/*"))
|
|
||||||
else launchCamera()
|
|
||||||
}
|
|
||||||
.setOnCancelListener {
|
|
||||||
pendingFileCallback?.onReceiveValue(null)
|
|
||||||
pendingFileCallback = null
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
else -> pickContentLauncher.launch(acceptMimes)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
container.addView(webView, FrameLayout.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
))
|
|
||||||
setContentView(container)
|
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(container) { v, insets ->
|
|
||||||
val bars = insets.getInsets(
|
|
||||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
|
||||||
)
|
|
||||||
v.setPadding(0, bars.top, 0, bars.bottom)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
container.viewTreeObserver.addOnGlobalLayoutListener {
|
|
||||||
recalculateBottomPadding(container)
|
|
||||||
}
|
|
||||||
|
|
||||||
setXWebViewController(object : XWebViewController {
|
|
||||||
override fun canGoBack(): Boolean = webView.canGoBack()
|
|
||||||
override fun canGoForward(): Boolean = webView.canGoForward()
|
|
||||||
override fun currentUrl(): String? = currentUrl ?: webView.url
|
|
||||||
override fun goBack() { if (webView.canGoBack()) webView.goBack() }
|
|
||||||
override fun goForward() { if (webView.canGoForward()) webView.goForward() }
|
|
||||||
override fun reload() { webView.reload() }
|
|
||||||
override fun loadUrl(url: String) { webView.loadUrl(url) }
|
|
||||||
override fun postMessageToWeb(js: String) { webView.evaluateJavascript(js, null) }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (config.url.isNotBlank()) {
|
|
||||||
webView.loadUrl(config.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recalculateBottomPadding(container: View) {
|
|
||||||
val visibleFrame = Rect()
|
|
||||||
container.getWindowVisibleDisplayFrame(visibleFrame)
|
|
||||||
val screenHeight = resources.displayMetrics.heightPixels
|
|
||||||
val bottomHidden = screenHeight - visibleFrame.bottom
|
|
||||||
val currentTop = container.paddingTop
|
|
||||||
val currentBottom = container.paddingBottom
|
|
||||||
val neededBottom = maxOf(currentBottom, bottomHidden)
|
|
||||||
if (neededBottom != currentBottom) {
|
|
||||||
container.setPadding(0, currentTop, 0, neededBottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("OVERRIDE_DEPRECATION")
|
|
||||||
override fun onBackPressed() {
|
|
||||||
if (webView.canGoBack()) webView.goBack() else super.onBackPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
setXWebViewController(null)
|
|
||||||
webView.destroy()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,6 +203,7 @@ internal fun String.escapeJs(): String = replace("\\", "\\\\")
|
|||||||
fun XWebViewView(
|
fun XWebViewView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
config: XWebViewConfig = getXWebViewConfig(),
|
config: XWebViewConfig = getXWebViewConfig(),
|
||||||
|
onNavigationChanged: ((canGoBack: Boolean, canGoForward: Boolean, pageTitle: String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
@ -287,6 +288,9 @@ fun XWebViewView(
|
|||||||
val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) }
|
val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) }
|
||||||
SideEffect { xwvMessageRef.value = xwvMessageHandler }
|
SideEffect { xwvMessageRef.value = xwvMessageHandler }
|
||||||
|
|
||||||
|
val onNavigationChangedRef = remember { mutableStateOf(onNavigationChanged) }
|
||||||
|
SideEffect { onNavigationChangedRef.value = onNavigationChanged }
|
||||||
|
|
||||||
// WebRTC getUserMedia() camera permission
|
// WebRTC getUserMedia() camera permission
|
||||||
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
|
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
|
||||||
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
|
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
@ -389,6 +393,13 @@ fun XWebViewView(
|
|||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
|
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
|
||||||
view?.evaluateJavascript(buildInjectedJs(config), null)
|
view?.evaluateJavascript(buildInjectedJs(config), null)
|
||||||
|
mainHandler.post {
|
||||||
|
onNavigationChangedRef.value?.invoke(
|
||||||
|
view?.canGoBack() == true,
|
||||||
|
view?.canGoForward() == true,
|
||||||
|
view?.title.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户