XuqmGroup-AndroidSDK/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt
XuqmGroup 6da62dc600 feat(sdk-webview): make JS bridge name configurable; default to XWebViewBridge
- Add `jsBridgeName` field to `XWebViewConfig` (default: "XWebViewBridge")
- Make `DIALOG_OVERRIDE_JS` a function parameterised by bridge name
- Use `config.jsBridgeName` in both `XWebViewView` and `XWebViewActivity`
- Bump sdk-webview to 1.0.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:50:46 +08:00

349 行
14 KiB
Kotlin

package com.xuqm.sdk.webview
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebViewClient
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
import java.util.Locale
// JS injected into every page to bridge dialog APIs and download interception.
// Uses addJavascriptInterface(jsBridgeName) for the JS→Native channel.
internal fun buildDialogOverrideJs(bridgeName: String) = """
(function() {
function post(obj) {
window.$bridgeName.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 =
buildDialogOverrideJs(config.jsBridgeName) + "\n" + (config.injectedJavaScript ?: "") + "\ntrue;"
internal fun shouldLoadInWebView(uri: Uri): Boolean {
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return true
return scheme in setOf("http", "https", "about", "data", "blob", "javascript")
}
internal fun openExternalScheme(context: Context, uri: Uri): Boolean {
return runCatching {
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
true
}.getOrDefault(false)
}
// 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) }
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun XWebViewView(
modifier: Modifier = Modifier,
config: XWebViewConfig = getXWebViewConfig(),
) {
val context = LocalContext.current
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
}
}
DisposableEffect(Unit) {
onDispose {
if (getXWebViewController() != null) {
setXWebViewController(null)
}
webView?.destroy()
webView = null
}
}
AndroidView(
modifier = modifier,
factory = { ctx ->
val wv = WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
config.userAgent?.let { settings.userAgentString = it }
// JS → Native bridge. Must be added before loadUrl.
addJavascriptInterface(
XWebViewJsBridge(mainHandler) { onMessageRef.value },
config.jsBridgeName,
)
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
currentUrl = url ?: view?.url
super.onPageFinished(view, url)
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
view?.evaluateJavascript(buildInjectedJs(config), null)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val uri = request?.url ?: return false
if (shouldLoadInWebView(uri)) {
return false
}
openExternalScheme(ctx, uri)
return true
}
}
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
}
}
if (config.url.isNotBlank()) {
loadUrl(config.url)
}
}
webView = wv
val container = FrameLayout(ctx)
container.addView(wv, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
))
container
},
update = { container ->
val wv = (container as? FrameLayout)?.getChildAt(0) as? WebView ?: return@AndroidView
if (wv.url.isNullOrBlank() && config.url.isNotBlank()) {
wv.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)
}
})
}
}