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

875 行
41 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.provider.OpenableColumns
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.RenderProcessGoneDetail
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
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
private const val XWV_FILE = "XWV-FilePicker"
// 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.
// Handles both individual entries and comma-separated values within a single entry
// (some WebView versions pass the raw accept attribute as one string, e.g. ".docx,.doc,.pdf").
internal fun resolvePickerMimeTypes(acceptTypes: Array<String>): Array<String> {
android.util.Log.d(XWV_FILE, "resolvePickerMimeTypes input: ${acceptTypes.toList()}")
// Flatten comma-separated entries that some WebView implementations return as a single string
val tokens = acceptTypes
.flatMap { it.split(',') }
.map { it.trim() }
.filter { it.isNotBlank() }
if (tokens.isEmpty()) {
android.util.Log.d(XWV_FILE, "resolvePickerMimeTypes: no tokens → [*/*]")
return arrayOf("*/*")
}
val result = tokens.map { type ->
if (type.startsWith(".")) {
val ext = type.trimStart('.').lowercase(Locale.ROOT)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
android.util.Log.d(XWV_FILE, " ext=$ext → mime=$mime")
mime ?: "*/*"
} else {
android.util.Log.d(XWV_FILE, " mime=$type (passthrough)")
type
}
}.distinct().toTypedArray()
android.util.Log.d(XWV_FILE, "resolvePickerMimeTypes output: ${result.toList()}")
return result
}
// 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")
/**
* Returns the storage permissions that should be requested before showing the file picker.
* - API 33+: granular media permissions keyed to the accepted MIME types
* - API < 33: READ_EXTERNAL_STORAGE
*/
internal fun requiredStoragePermissions(acceptMimes: Array<String>): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
buildList {
val any = acceptMimes.any { it == "*/*" }
if (any || acceptMimes.any { it.startsWith("image/") }) add(Manifest.permission.READ_MEDIA_IMAGES)
if (any || acceptMimes.any { it.startsWith("video/") }) add(Manifest.permission.READ_MEDIA_VIDEO)
if (any || acceptMimes.any { it.startsWith("audio/") }) add(Manifest.permission.READ_MEDIA_AUDIO)
}.toTypedArray()
} else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
internal fun storagePermissionsGranted(context: Context, acceptMimes: Array<String>): Boolean =
requiredStoragePermissions(acceptMimes).all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
/**
* Copies the content pointed to by [uri] into a stable local cache file so that WebView's
* file-chooser callback can read it regardless of where the file was picked from.
*
* Cloud/media provider URIs (Google Drive, Photos, OPPO Gallery, etc.) are only readable
* by the app process that received the picker result; some break if the WebView chromium
* renderer tries to open them later. Returning a plain file:// URI from the app's cache
* dir avoids the problem entirely.
*
* Returns null if the stream cannot be opened (caller should fall back to the original URI).
*/
internal fun copyUriToWebViewCache(context: Context, uri: Uri, mimeType: String?): Uri? {
return runCatching {
val displayName = context.contentResolver.query(
uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
?: displayName?.substringAfterLast('.', "").takeIf { !it.isNullOrBlank() }
?: "bin"
val base = displayName?.substringBeforeLast('.')
?.replace(Regex("[^a-zA-Z0-9_\\-]"), "_")
?.take(64)
?: "upload"
val dir = File(context.cacheDir, "webview_uploads").also { it.mkdirs() }
val dest = File(dir, "${base}_${System.currentTimeMillis()}.$ext")
val bytesRead = context.contentResolver.openInputStream(uri)?.use { input ->
dest.outputStream().use { out -> input.copyTo(out) }
} ?: return null // openInputStream returned null — URI unreadable
android.util.Log.d("XWV", "copyUriToWebViewCache: $uri${dest.name} ($bytesRead bytes)")
Uri.fromFile(dest)
}.getOrElse { e ->
android.util.Log.w("XWV", "copyUriToWebViewCache failed for $uri: ${e.message}")
null
}
}
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 pendingAcceptMimes = remember { mutableStateOf<Array<String>?>(null) }
// Mimes saved while waiting for storage permission; launcher fires the picker once granted/denied.
val pendingPickerMimes = remember { mutableStateOf<Array<String>?>(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
android.util.Log.d(XWV_FILE, "pickContentLauncher result: uri=$uri")
if (uri == null) {
android.util.Log.d(XWV_FILE, "pickContentLauncher: user cancelled (uri=null)")
cb?.onReceiveValue(null)
return@rememberLauncherForActivityResult
}
val acceptedMimes = pendingAcceptMimes.value
// ContentResolver returns "application/octet-stream" (or null) for most Office files
// from file-system / Downloads providers. Fall back to extension-based inference so
// .docx / .doc / .xls etc. are not incorrectly rejected.
val rawMime = context.contentResolver.getType(uri)
val displayName = context.contentResolver.query(
uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
android.util.Log.d(XWV_FILE, " displayName=$displayName rawMime=$rawMime acceptedMimes=$acceptedMimes")
val actualMime: String? = if (rawMime == null || rawMime == "application/octet-stream") {
val ext = displayName?.substringAfterLast('.', "")
?.lowercase(Locale.ROOT)
?.takeIf { it.isNotEmpty() }
val extMime = ext?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) }
android.util.Log.d(XWV_FILE, " rawMime generic → ext=$ext extMime=$extMime")
extMime ?: rawMime
} else rawMime
android.util.Log.d(XWV_FILE, " actualMime=$actualMime")
val allowed = acceptedMimes.isNullOrEmpty() ||
acceptedMimes.any { accepted ->
accepted == "*/*" ||
actualMime == null ||
actualMime == accepted ||
actualMime.startsWith(accepted.substringBefore("*"))
}
android.util.Log.d(XWV_FILE, " allowed=$allowed")
if (!allowed) {
android.util.Log.w(XWV_FILE, "Rejected: actualMime=$actualMime not in accepted=$acceptedMimes")
cb?.onReceiveValue(null)
return@rememberLauncherForActivityResult
}
coroutineScope.launch(Dispatchers.IO) {
android.util.Log.d(XWV_FILE, "copyUriToWebViewCache start: uri=$uri mime=$actualMime")
val localUri = copyUriToWebViewCache(context, uri, actualMime) ?: uri
android.util.Log.d(XWV_FILE, "copyUriToWebViewCache done: localUri=$localUri")
withContext(Dispatchers.Main) {
android.util.Log.d(XWV_FILE, "onReceiveValue: ${arrayOf(localUri).toList()}")
cb?.onReceiveValue(arrayOf(localUri))
}
}
}
// Storage read permission launcher (READ_EXTERNAL_STORAGE on API < 33; granular media on 33+).
// Always fires the picker after the result — if the user denied, SAF still shows accessible files.
val storageReadPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
android.util.Log.d(XWV_FILE, "storageReadPermissionLauncher result: $results")
val mimes = pendingPickerMimes.value ?: return@rememberLauncherForActivityResult
pendingPickerMimes.value = null
android.util.Log.d(XWV_FILE, " launching picker after permission result: ${mimes.toList()}")
pickContentLauncher.launch(mimes)
}
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) }
}
// Checks storage permissions before launching the file picker.
// Requests missing permissions on first use; launches the picker after the result either way.
val launchPickerWithPermission: (Array<String>) -> Unit = { mimes ->
val granted = storagePermissionsGranted(context, mimes)
val required = requiredStoragePermissions(mimes)
android.util.Log.d(XWV_FILE, "launchPickerWithPermission: mimes=${mimes.toList()} permissionsGranted=$granted required=${required.toList()}")
if (granted) {
android.util.Log.d(XWV_FILE, " permissions ok → launch picker")
pickContentLauncher.launch(mimes)
} else {
android.util.Log.d(XWV_FILE, " requesting permissions: ${required.toList()}")
pendingPickerMimes.value = mimes
storageReadPermissionLauncher.launch(required)
}
}
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
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
super.onReceivedError(view, request, error)
// 仅处理主框架加载错误忽略子资源如图片、JS 等)
if (request?.isForMainFrame == true) {
val code = error?.errorCode ?: -1
val desc = error?.description?.toString() ?: "Unknown error"
val url = request.url?.toString() ?: ""
android.util.Log.e("XWV", "onReceivedError: code=$code, desc=$desc, url=$url")
mainHandler.post { config.onPageError?.invoke(code, desc, url) }
}
}
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
// WebView renderer (sandboxed process) crashed or was OOM-killed.
// Return true to keep the main app alive; the crashed WebView instance
// can no longer be used — reload the page via the config url.
android.util.Log.e("XWV", "WebView renderer gone: crashed=${detail.didCrash()}")
mainHandler.post {
// Report the renderer crash via BugCollect if available.
runCatching {
val bugCollect = Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
val instance = bugCollect.getField("INSTANCE").get(null)
val captureMethod = bugCollect.getMethod(
"captureError",
Throwable::class.java,
Map::class.java,
String::class.java,
)
captureMethod.invoke(
instance,
RuntimeException("WebView renderer ${if (detail.didCrash()) "crashed" else "killed (OOM)"}"),
mapOf("context" to "webview_renderer"),
null,
)
}
// Reload the page so the user is not stuck on a blank screen.
if (config.url.isNotBlank()) view.loadUrl(config.url)
}
return true
}
2026-05-07 19:39:38 +08:00
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
super.onProgressChanged(view, newProgress)
mainHandler.post { config.onProgressChanged?.invoke(newProgress) }
}
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
android.util.Log.d(XWV_FILE, "onShowFileChooser: acceptTypes=${fileChooserParams.acceptTypes.toList()} isCaptureEnabled=${fileChooserParams.isCaptureEnabled} mode=${fileChooserParams.mode}")
val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes)
pendingAcceptMimes.value = acceptMimes
val isCameraCapture = fileChooserParams.isCaptureEnabled &&
fileChooserParams.acceptTypes.any { it.contains("image") }
val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" }
android.util.Log.d(XWV_FILE, " resolvedMimes=${acceptMimes.toList()} isCameraCapture=$isCameraCapture isImageOnly=$isImageOnly")
when {
isCameraCapture -> { android.util.Log.d(XWV_FILE, " → launchCamera"); launchCamera() }
isImageOnly -> { android.util.Log.d(XWV_FILE, " → showImageSourceDialog"); showImageSourceDialog = true }
else -> { android.util.Log.d(XWV_FILE, " → launchPickerWithPermission(${acceptMimes.toList()})"); launchPickerWithPermission(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
launchPickerWithPermission(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
})
}
}