diff --git a/README.md b/README.md index bbea774..0c4ca1b 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ NEXUS_PASSWORD=your_password 引入依赖: ```kotlin dependencies { - implementation("com.xuqm:sdk-core:0.4.0") - implementation("com.xuqm:sdk-im:0.4.0") // 可选 - implementation("com.xuqm:sdk-push:0.4.0") // 可选 - implementation("com.xuqm:sdk-update:0.4.0") // 可选 - implementation("com.xuqm:sdk-webview:0.4.0") // 可选 + implementation("com.xuqm:sdk-core:0.4.2") + implementation("com.xuqm:sdk-im:0.4.2") // 可选 + implementation("com.xuqm:sdk-push:0.4.2") // 可选 + implementation("com.xuqm:sdk-update:0.4.2") // 可选 + implementation("com.xuqm:sdk-webview:0.4.2") // 可选 } ``` diff --git a/gradle.properties b/gradle.properties index b8022ca..df9d799 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true -PUBLISH_VERSION=0.4.1 +PUBLISH_VERSION=0.4.3 diff --git a/sdk-core/build.gradle.kts b/sdk-core/build.gradle.kts index 1969784..df4b86d 100644 --- a/sdk-core/build.gradle.kts +++ b/sdk-core/build.gradle.kts @@ -25,7 +25,9 @@ android { } publishing { - singleVariant("release") + singleVariant("release") { + withSourcesJar() + } } } diff --git a/sdk-im/build.gradle.kts b/sdk-im/build.gradle.kts index 1ea9cfa..5055b3a 100644 --- a/sdk-im/build.gradle.kts +++ b/sdk-im/build.gradle.kts @@ -19,7 +19,9 @@ android { } publishing { - singleVariant("release") + singleVariant("release") { + withSourcesJar() + } } } diff --git a/sdk-push/build.gradle.kts b/sdk-push/build.gradle.kts index 4590de1..94faa68 100644 --- a/sdk-push/build.gradle.kts +++ b/sdk-push/build.gradle.kts @@ -22,7 +22,9 @@ android { } publishing { - singleVariant("release") + singleVariant("release") { + withSourcesJar() + } } } diff --git a/sdk-update/build.gradle.kts b/sdk-update/build.gradle.kts index 0d74824..fb10a33 100644 --- a/sdk-update/build.gradle.kts +++ b/sdk-update/build.gradle.kts @@ -19,7 +19,9 @@ android { } publishing { - singleVariant("release") + singleVariant("release") { + withSourcesJar() + } } } diff --git a/sdk-webview/build.gradle.kts b/sdk-webview/build.gradle.kts index 1b63937..e569f44 100644 --- a/sdk-webview/build.gradle.kts +++ b/sdk-webview/build.gradle.kts @@ -24,7 +24,9 @@ android { } publishing { - singleVariant("release") + singleVariant("release") { + withSourcesJar() + } } } diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewBridge.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewBridge.kt index 538d177..f14bec7 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewBridge.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewBridge.kt @@ -21,19 +21,9 @@ object XWebViewControl : XWebViewController { override fun canGoBack(): Boolean = currentController?.canGoBack() ?: false override fun canGoForward(): Boolean = currentController?.canGoForward() ?: false override fun currentUrl(): String? = currentController?.currentUrl() - override fun goBack() { - currentController?.goBack() - } - - override fun goForward() { - currentController?.goForward() - } - - override fun reload() { - currentController?.reload() - } - - override fun loadUrl(url: String) { - currentController?.loadUrl(url) - } + override fun goBack() { currentController?.goBack() } + override fun goForward() { currentController?.goForward() } + override fun reload() { currentController?.reload() } + override fun loadUrl(url: String) { currentController?.loadUrl(url) } + override fun postMessageToWeb(js: String) { currentController?.postMessageToWeb(js) } } diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt index c559149..7d2affa 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewTypes.kt @@ -6,6 +6,8 @@ data class XWebViewConfig( val hideToolbar: Boolean = false, val hideStatusBar: Boolean = false, val userAgent: String? = null, + val injectedJavaScript: String? = null, + val onMessage: ((String) -> Unit)? = null, ) interface XWebViewController { @@ -16,4 +18,5 @@ interface XWebViewController { fun goForward() fun reload() fun loadUrl(url: String) + fun postMessageToWeb(js: String) } diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt index e67cdb9..b1f7217 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt @@ -1,19 +1,122 @@ package com.xuqm.sdk.webview +import android.Manifest 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 import android.webkit.WebResourceRequest import android.webkit.WebViewClient import android.webkit.WebView +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.LaunchedEffect 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 + +// JS injected into every page to bridge dialog APIs and download interception. +// Uses addJavascriptInterface("ReactNativeWebView") for the JS→Native channel. +private 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() + +private 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. +private class XWebViewJsBridge( + private val mainHandler: Handler, + private val onMessage: () -> ((String) -> Unit)?, +) { + @JavascriptInterface + fun postMessage(data: String) { + mainHandler.post { onMessage()?.invoke(data) } + } +} @SuppressLint("SetJavaScriptEnabled") @Composable @@ -21,8 +124,70 @@ fun XWebViewView( modifier: Modifier = Modifier, config: XWebViewConfig = getXWebViewConfig(), ) { + val context = LocalContext.current var webView by remember { mutableStateOf(null) } var currentUrl by remember { mutableStateOf(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(null) } + val webRtcPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + val req = pendingWebRtcRequest.value + pendingWebRtcRequest.value = null + if (granted) req?.grant(req.resources) else req?.deny() + } + + // camera capture + val pendingFileCallback = remember { mutableStateOf>?>(null) } + val pendingCameraUri = remember { mutableStateOf(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) { @@ -35,21 +200,82 @@ fun XWebViewView( AndroidView( modifier = modifier, - factory = { context -> - WebView(context).apply { + factory = { ctx -> + 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 }, + "ReactNativeWebView", + ) + webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { - currentUrl = url ?: view?.url?.toString() + 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 { 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>, + 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) } @@ -81,6 +307,9 @@ fun XWebViewView( override fun loadUrl(url: String) { view.loadUrl(url) } + override fun postMessageToWeb(js: String) { + view.evaluateJavascript(js, null) + } }) } }