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

668 行
29 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.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.MimeTypeMap
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 android.widget.FrameLayout
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
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.rememberCoroutineScope
2026-05-07 19:39:38 +08:00
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
2026-05-07 19:39:38 +08:00
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.xuqm.sdk.file.FileDownloadDestination
import com.xuqm.sdk.file.FileSDK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.util.Locale
// Maps HTML accept types (MIME or dot-prefixed extensions) to an array of MIME strings for ACTION_GET_CONTENT.
// Returns ["*/*"] when types are empty or cannot be resolved.
internal fun resolvePickerMimeTypes(acceptTypes: Array<String>): Array<String> {
val nonBlank = acceptTypes.filter { it.isNotBlank() }
if (nonBlank.isEmpty()) return arrayOf("*/*")
return nonBlank.map { type ->
if (type.startsWith(".")) {
MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT))
?: "*/*"
} else {
type
}
}.distinct().toTypedArray()
}
// 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.
// 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) {
console.log('[XWV] readBlobAndPost start, url=' + blobUrl + ', filename=' + filename);
fetch(blobUrl)
.then(function(r) {
console.log('[XWV] fetch ok, size=' + r.size);
return r.blob();
})
.then(function(blob) {
console.log('[XWV] blob ok, size=' + blob.size + ', type=' + blob.type);
var reader = new FileReader();
reader.onloadend = function() {
var b64 = reader.result.split(',')[1];
console.log('[XWV] base64 ready, len=' + (b64 ? b64.length : 0));
post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 });
console.log('[XWV] blobdownload posted');
};
reader.onerror = function(e) {
console.log('[XWV] FileReader error: ' + String(e));
post({ __xwv: 'bloberror', msg: 'FileReader error: ' + String(e) });
};
reader.readAsDataURL(blob);
})
.catch(function(err) {
console.log('[XWV] readBlobAndPost ERROR: ' + String(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);
console.log('[XWV] tryInterceptAnchor href=' + href + ', hasDownload=' + hasDownloadAttr + ', isDL=' + isDL);
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 JS messages to onMessage (business) or onXwvMessage (internal __xwv events).
// @JavascriptInterface is called on a background thread; all delivery is posted to main.
internal class XWebViewJsBridge(
private val mainHandler: Handler,
private val onMessage: () -> ((String) -> Unit)?,
private val onXwvMessage: () -> ((String, JSONObject) -> Unit)?,
) {
@JavascriptInterface
fun postMessage(data: String) {
android.util.Log.d("XWV", "postMessage: ${data.take(200)}")
mainHandler.post {
val json = runCatching { JSONObject(data) }.getOrNull()
val xwv = json?.optString("__xwv")?.takeIf { it.isNotEmpty() }
android.util.Log.d("XWV", "postMessage parsed: xwv=$xwv")
if (xwv != null && json != null) {
onXwvMessage()?.invoke(xwv, json)
} else {
onMessage()?.invoke(data)
}
}
}
}
2026-05-07 19:39:38 +08:00
/** Escapes a string for safe inline use inside a JS string literal. */
internal fun String.escapeJs(): String = replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
2026-05-07 19:39:38 +08:00
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun XWebViewView(
modifier: Modifier = Modifier,
config: XWebViewConfig = getXWebViewConfig(),
onNavigationChanged: ((canGoBack: Boolean, canGoForward: Boolean, pageTitle: String) -> Unit)? = null,
2026-05-07 19:39:38 +08:00
) {
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 }) }
val coroutineScope = rememberCoroutineScope()
var showImageSourceDialog by remember { mutableStateOf(false) }
var showStoragePermissionDialog by remember { mutableStateOf(false) }
val pendingUrlDownload = remember { mutableStateOf<Pair<String, String?>?>(null) }
// blobdownload pending: (url, filename, base64Data)
val pendingBlobDownload = remember { mutableStateOf<Triple<String, String, String>?>(null) }
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.
val onMessageRef = remember { mutableStateOf(config.onMessage) }
SideEffect { onMessageRef.value = config.onMessage }
val mainHandler = remember { Handler(Looper.getMainLooper()) }
// Injects a CustomEvent into the current page to report download progress/completion.
fun dispatchDownloadEvent(eventName: String, url: String, extra: String = "") {
val js = "window.dispatchEvent(new CustomEvent('$eventName',{detail:{url:'${url.escapeJs()}'$extra}}));"
webView?.evaluateJavascript(js, null)
}
// Handles __xwv: 'download' / 'blobdownload' messages from the injected JS.
val xwvMessageHandler: (String, JSONObject) -> Unit = handler@{ type, payload ->
android.util.Log.d("XWV", "xwvMessage type=$type, payload=${payload.toString().take(200)}")
when (type) {
"download" -> {
val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler
val filename = payload.optString("filename").takeIf { it.isNotBlank() }
// On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE.
// Show a rationale dialog; after the user grants permission and returns, the
// lifecycle observer will resume the download automatically.
if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
!FileSDK.isManageStorageGranted(context)
) {
pendingUrlDownload.value = url to filename
showStoragePermissionDialog = true
return@handler
}
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) {
runCatching {
FileSDK.download(
context = context,
downloadUrl = url,
fileName = filename,
destination = config.downloadDestination,
notificationTitle = config.downloadNotificationTitle,
) { progress ->
mainHandler.post {
dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
}
}
}.onSuccess { file ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
FileSDK.openFile(context, file)
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
"blobdownload" -> {
val url = payload.optString("url")
val filename = payload.optString("filename").takeIf { it.isNotBlank() } ?: "download.bin"
val b64 = payload.optString("data").takeIf { it.isNotBlank() } ?: run {
android.util.Log.e("XWV", "blobdownload: empty data")
return@handler
}
// On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE.
if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
!FileSDK.isManageStorageGranted(context)
) {
pendingBlobDownload.value = Triple(url, filename, b64)
showStoragePermissionDialog = true
return@handler
}
android.util.Log.d("XWV", "blobdownload: filename=$filename, b64Len=${b64.length}, dest=${config.downloadDestination}")
coroutineScope.launch(Dispatchers.IO) {
runCatching {
FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
}.onSuccess { file ->
android.util.Log.d("XWV", "blobdownload saved: ${file.absolutePath}, size=${file.length()}")
// For image files, save to the system gallery (相册) so they appear in Photos
val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false)
android.util.Log.d("XWV", "blobdownload savedToGallery=$savedToGallery")
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
// Only open with file viewer when not saved to gallery (e.g. zip, docx)
if (!savedToGallery) FileSDK.openFile(context, file)
}
}.onFailure { e ->
android.util.Log.e("XWV", "blobdownload FAILED", e)
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
}
}
val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) }
SideEffect { xwvMessageRef.value = xwvMessageHandler }
val onNavigationChangedRef = remember { mutableStateOf(onNavigationChanged) }
SideEffect { onNavigationChangedRef.value = onNavigationChanged }
// 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(GetContentWithMimeTypes()) { 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
}
}
// When returning from the MANAGE_EXTERNAL_STORAGE settings page, resume pending download.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
val pd = pendingUrlDownload.value
val pbd = pendingBlobDownload.value
if (pd != null && FileSDK.isManageStorageGranted(context)) {
val (url, filename) = pd
pendingUrlDownload.value = null
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) {
runCatching {
FileSDK.download(
context = context,
downloadUrl = url,
fileName = filename,
destination = config.downloadDestination,
notificationTitle = config.downloadNotificationTitle,
) { progress ->
mainHandler.post {
dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
}
}
}.onSuccess { file ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
FileSDK.openFile(context, file)
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
if (pbd != null && FileSDK.isManageStorageGranted(context)) {
val (url, filename, b64) = pbd
pendingBlobDownload.value = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
}.onSuccess { file ->
android.util.Log.d("XWV", "blobdownload saved: ${file.absolutePath}, size=${file.length()}")
val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false)
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
if (!savedToGallery) FileSDK.openFile(context, file)
}
}.onFailure { e ->
android.util.Log.e("XWV", "blobdownload FAILED", e)
withContext(Dispatchers.Main) {
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
}
}
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
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)
}
}
2026-05-07 19:39:38 +08:00
AndroidView(
modifier = modifier,
factory = { ctx ->
WebView.setWebContentsDebuggingEnabled(config.debugEnabled)
val wv = 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 }, { xwvMessageRef.value }),
config.jsBridgeName,
)
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)
mainHandler.post {
onNavigationChangedRef.value?.invoke(
view?.canGoBack() == true,
view?.canGoForward() == true,
view?.title.orEmpty(),
)
}
2026-05-07 19:39:38 +08:00
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val uri = request?.url ?: return false
if (shouldLoadInWebView(uri)) {
return false
}
openExternalScheme(ctx, uri)
return true
2026-05-07 19:39:38 +08:00
}
}
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 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 -> showImageSourceDialog = true
else -> pickContentLauncher.launch(acceptMimes)
}
return true
}
}
2026-05-07 19:39:38 +08:00
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
2026-05-07 19:39:38 +08:00
},
update = { container ->
val wv = (container as? FrameLayout)?.getChildAt(0) as? WebView ?: return@AndroidView
if (wv.url.isNullOrBlank() && config.url.isNotBlank()) {
wv.loadUrl(config.url)
2026-05-07 19:39:38 +08:00
}
},
)
if (showStoragePermissionDialog) {
AlertDialog(
onDismissRequest = {
showStoragePermissionDialog = false
pendingUrlDownload.value = null
pendingBlobDownload.value = null
},
title = { Text("需要存储权限") },
text = { Text("下载文件需要「所有文件访问权限」,请在设置中授权后将自动继续下载。") },
confirmButton = {
TextButton(onClick = {
showStoragePermissionDialog = false
FileSDK.requestManageStorageIntent(context)?.let { context.startActivity(it) }
}) { Text("前往设置") }
},
dismissButton = {
TextButton(onClick = {
showStoragePermissionDialog = false
pendingUrlDownload.value = null
pendingBlobDownload.value = null
}) { Text("取消") }
},
)
}
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("拍照") }
},
)
}
2026-05-07 19:39:38 +08:00
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
})
}
}