XuqmGroup-AndroidSDK/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt

316 行
12 KiB
Kotlin

2026-05-07 19:39:38 +08:00
package com.xuqm.sdk.webview
import android.Manifest
2026-05-07 19:39:38 +08:00
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
2026-05-07 19:39:38 +08:00
import android.webkit.WebResourceRequest
import android.webkit.WebViewClient
import android.webkit.WebView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
2026-05-07 19:39:38 +08:00
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
2026-05-07 19:39:38 +08:00
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
// JS injected into every page to bridge dialog APIs and download interception.
// Uses addJavascriptInterface("ReactNativeWebView") for the JS→Native channel.
internal val DIALOG_OVERRIDE_JS = """
(function() {
function post(obj) {
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
}
window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); };
window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; };
window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; };
window.open = function(url) {
if (url) { window.location.href = url; }
return null;
};
URL.revokeObjectURL = function() {};
function readBlobAndPost(blobUrl, filename) {
fetch(blobUrl)
.then(function(r) { return r.blob(); })
.then(function(blob) {
var reader = new FileReader();
reader.onloadend = function() {
var b64 = reader.result.split(',')[1];
post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 });
};
reader.readAsDataURL(blob);
})
.catch(function(err) {
post({ __xwv: 'bloberror', msg: String(err) });
});
}
var DL_RE = "\\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\\?|#|${'$'})";
var dlRe = new RegExp(DL_RE, 'i');
function tryInterceptAnchor(el, e) {
if (!el || el.tagName !== 'A') return false;
var href = el.href || el.getAttribute('href') || '';
if (!href || href.indexOf('javascript') === 0) return false;
var hasDownloadAttr = el.hasAttribute('download');
var dlName = el.getAttribute('download') || '';
var isDL = hasDownloadAttr || dlRe.test(href);
if (isDL) {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (href.startsWith('blob:')) {
readBlobAndPost(href, dlName);
} else {
post({ __xwv: 'download', url: href, filename: dlName });
}
return true;
}
return false;
}
document.addEventListener('click', function(e) {
var el = e.target;
while (el && el.tagName !== 'A') { el = el.parentElement; }
tryInterceptAnchor(el, e);
}, true);
var _origClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function() {
if (!tryInterceptAnchor(this, null)) {
_origClick.call(this);
}
};
})();
true;
""".trimIndent()
internal fun buildInjectedJs(config: XWebViewConfig): String =
DIALOG_OVERRIDE_JS + "\n" + (config.injectedJavaScript ?: "") + "\ntrue;"
// Routes window.ReactNativeWebView.postMessage() calls to [onMessage].
// @JavascriptInterface methods are called on a background thread; we post to main.
internal class XWebViewJsBridge(
private val mainHandler: Handler,
private val onMessage: () -> ((String) -> Unit)?,
) {
@JavascriptInterface
fun postMessage(data: String) {
mainHandler.post { onMessage()?.invoke(data) }
}
}
2026-05-07 19:39:38 +08:00
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun XWebViewView(
modifier: Modifier = Modifier,
config: XWebViewConfig = getXWebViewConfig(),
) {
val context = LocalContext.current
2026-05-07 19:39:38 +08:00
var webView by remember { mutableStateOf<WebView?>(null) }
var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) }
// Keep onMessage ref up-to-date across recompositions without recreating the bridge object.
val onMessageRef = remember { mutableStateOf(config.onMessage) }
SideEffect { onMessageRef.value = config.onMessage }
val mainHandler = remember { Handler(Looper.getMainLooper()) }
// WebRTC getUserMedia() camera permission
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
val req = pendingWebRtcRequest.value
pendingWebRtcRequest.value = null
if (granted) req?.grant(req.resources) else req?.deny()
}
// <input type="file" capture> camera capture
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
val pendingCameraUri = remember { mutableStateOf<Uri?>(null) }
val takePictureLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
) { success ->
val cb = pendingFileCallback.value
val uri = pendingCameraUri.value
pendingFileCallback.value = null
pendingCameraUri.value = null
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
}
val pickContentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
val cb = pendingFileCallback.value
pendingFileCallback.value = null
cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null)
}
val shouldLaunchCamera = remember { mutableStateOf(false) }
val fileCameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
val launch = shouldLaunchCamera.value
shouldLaunchCamera.value = false
if (granted && launch) {
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 {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = null
}
}
2026-05-07 19:39:38 +08:00
DisposableEffect(Unit) {
onDispose {
if (getXWebViewController() != null) {
setXWebViewController(null)
}
webView?.destroy()
webView = null
}
}
AndroidView(
modifier = modifier,
factory = { ctx ->
WebView(ctx).apply {
2026-05-07 19:39:38 +08:00
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
2026-05-07 19:39:38 +08:00
config.userAgent?.let { settings.userAgentString = it }
// JS → Native bridge. Must be added before loadUrl.
addJavascriptInterface(
XWebViewJsBridge(mainHandler) { onMessageRef.value },
"ReactNativeWebView",
)
2026-05-07 19:39:38 +08:00
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
currentUrl = url ?: view?.url
2026-05-07 19:39:38 +08:00
super.onPageFinished(view, url)
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
view?.evaluateJavascript(buildInjectedJs(config), null)
2026-05-07 19:39:38 +08:00
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false
}
}
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) {
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
request.grant(request.resources)
} else {
pendingWebRtcRequest.value = request
webRtcPermissionLauncher.launch(Manifest.permission.CAMERA)
}
} else {
request.deny()
}
}
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams,
): Boolean {
pendingFileCallback.value?.onReceiveValue(null)
pendingFileCallback.value = filePathCallback
val isCameraCapture = fileChooserParams.isCaptureEnabled &&
fileChooserParams.acceptTypes.any { it.contains("image") }
if (isCameraCapture) {
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
val imageFile = File.createTempFile("cam_", ".jpg", ctx.cacheDir)
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 {
val mimeType = fileChooserParams.acceptTypes
.firstOrNull { it.isNotBlank() } ?: "image/*"
pickContentLauncher.launch(mimeType)
}
return true
}
}
2026-05-07 19:39:38 +08:00
if (config.url.isNotBlank()) {
loadUrl(config.url)
}
webView = this
}
},
update = { view ->
if (view.url.isNullOrBlank() && config.url.isNotBlank()) {
view.loadUrl(config.url)
}
},
)
SideEffect {
val view = webView ?: return@SideEffect
setXWebViewController(object : XWebViewController {
override fun canGoBack(): Boolean = view.canGoBack()
override fun canGoForward(): Boolean = view.canGoForward()
override fun currentUrl(): String? = currentUrl ?: view.url
override fun goBack() {
if (view.canGoBack()) view.goBack()
}
override fun goForward() {
if (view.canGoForward()) view.goForward()
}
override fun reload() {
view.reload()
}
override fun loadUrl(url: String) {
view.loadUrl(url)
}
override fun postMessageToWeb(js: String) {
view.evaluateJavascript(js, null)
}
2026-05-07 19:39:38 +08:00
})
}
}