diff --git a/gradle.properties b/gradle.properties index df9d799..ce5f709 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.3 +PUBLISH_VERSION=0.4.9 diff --git a/sdk-webview/src/main/AndroidManifest.xml b/sdk-webview/src/main/AndroidManifest.xml index 94cbbcf..cef4c1f 100644 --- a/sdk-webview/src/main/AndroidManifest.xml +++ b/sdk-webview/src/main/AndroidManifest.xml @@ -1 +1,11 @@ - + + + + + + + \ No newline at end of file diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt new file mode 100644 index 0000000..fb0b0e9 --- /dev/null +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt @@ -0,0 +1,222 @@ +package com.xuqm.sdk.webview + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import java.io.File + +class XWebViewActivity : ComponentActivity() { + + private lateinit var webView: WebView + private val mainHandler = Handler(Looper.getMainLooper()) + private var pendingWebRtcRequest: PermissionRequest? = null + private var pendingFileCallback: ValueCallback>? = null + private var pendingCameraUri: Uri? = null + private var shouldLaunchCamera = false + private var currentUrl: String? = null + + private val webRtcPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + val req = pendingWebRtcRequest + pendingWebRtcRequest = null + if (granted) req?.grant(req.resources) else req?.deny() + } + + private val takePictureLauncher = registerForActivityResult( + ActivityResultContracts.TakePicture() + ) { success -> + val cb = pendingFileCallback + val uri = pendingCameraUri + pendingFileCallback = null + pendingCameraUri = null + cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null) + } + + private val pickContentLauncher = registerForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + val cb = pendingFileCallback + pendingFileCallback = null + cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null) + } + + private val fileCameraPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + val launch = shouldLaunchCamera + shouldLaunchCamera = false + if (granted && launch) { + runCatching { + val imageFile = File.createTempFile("cam_", ".jpg", cacheDir) + val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", imageFile) + pendingCameraUri = uri + takePictureLauncher.launch(uri) + }.onFailure { + pendingFileCallback?.onReceiveValue(null) + pendingFileCallback = null + } + } else { + pendingFileCallback?.onReceiveValue(null) + pendingFileCallback = null + } + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + + val config = getXWebViewConfig() + + val container = FrameLayout(this) + webView = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + config.userAgent?.let { settings.userAgentString = it } + + addJavascriptInterface( + XWebViewJsBridge(mainHandler) { config.onMessage }, + "ReactNativeWebView", + ) + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + currentUrl = url ?: view?.url + super.onPageFinished(view, url) + view?.evaluateJavascript(buildInjectedJs(config), null) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false + } + + webChromeClient = object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) { + if (ContextCompat.checkSelfPermission(this@XWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + request.grant(request.resources) + } else { + pendingWebRtcRequest = request + webRtcPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } else { + request.deny() + } + } + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams, + ): Boolean { + pendingFileCallback?.onReceiveValue(null) + pendingFileCallback = filePathCallback + + val isCameraCapture = fileChooserParams.isCaptureEnabled && + fileChooserParams.acceptTypes.any { it.contains("image") } + + if (isCameraCapture) { + if (ContextCompat.checkSelfPermission(this@XWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + runCatching { + val imageFile = File.createTempFile("cam_", ".jpg", cacheDir) + val uri = FileProvider.getUriForFile(this@XWebViewActivity, "${packageName}.fileprovider", imageFile) + pendingCameraUri = uri + takePictureLauncher.launch(uri) + }.onFailure { + pendingFileCallback?.onReceiveValue(null) + pendingFileCallback = null + } + } else { + shouldLaunchCamera = true + fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } else { + val mimeType = fileChooserParams.acceptTypes + .firstOrNull { it.isNotBlank() } ?: "image/*" + pickContentLauncher.launch(mimeType) + } + return true + } + } + } + container.addView(webView, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + )) + setContentView(container) + + ViewCompat.setOnApplyWindowInsetsListener(container) { v, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + v.setPadding(0, bars.top, 0, bars.bottom) + insets + } + + container.viewTreeObserver.addOnGlobalLayoutListener { + recalculateBottomPadding(container) + } + + setXWebViewController(object : XWebViewController { + override fun canGoBack(): Boolean = webView.canGoBack() + override fun canGoForward(): Boolean = webView.canGoForward() + override fun currentUrl(): String? = currentUrl ?: webView.url + override fun goBack() { if (webView.canGoBack()) webView.goBack() } + override fun goForward() { if (webView.canGoForward()) webView.goForward() } + override fun reload() { webView.reload() } + override fun loadUrl(url: String) { webView.loadUrl(url) } + override fun postMessageToWeb(js: String) { webView.evaluateJavascript(js, null) } + }) + + if (config.url.isNotBlank()) { + webView.loadUrl(config.url) + } + } + + private fun recalculateBottomPadding(container: View) { + val visibleFrame = Rect() + container.getWindowVisibleDisplayFrame(visibleFrame) + val screenHeight = resources.displayMetrics.heightPixels + val bottomHidden = screenHeight - visibleFrame.bottom + val currentTop = container.paddingTop + val currentBottom = container.paddingBottom + val neededBottom = maxOf(currentBottom, bottomHidden) + if (neededBottom != currentBottom) { + container.setPadding(0, currentTop, 0, neededBottom) + } + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onBackPressed() { + if (webView.canGoBack()) webView.goBack() else super.onBackPressed() + } + + override fun onDestroy() { + setXWebViewController(null) + webView.destroy() + super.onDestroy() + } +} \ No newline at end of file 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 f14bec7..fbc6315 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 @@ -1,5 +1,7 @@ package com.xuqm.sdk.webview +import android.content.Context +import android.content.Intent import androidx.compose.runtime.mutableStateOf private val currentConfig = mutableStateOf(XWebViewConfig()) @@ -9,6 +11,11 @@ fun openXWebView(config: XWebViewConfig) { currentConfig.value = config } +fun openXWebView(context: Context, config: XWebViewConfig) { + currentConfig.value = config + context.startActivity(Intent(context, XWebViewActivity::class.java)) +} + fun getXWebViewConfig(): XWebViewConfig = currentConfig.value fun setXWebViewController(controller: XWebViewController?) { 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 b1f7217..cbb9809 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 @@ -32,7 +32,7 @@ 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 = """ +internal val DIALOG_OVERRIDE_JS = """ (function() { function post(obj) { window.ReactNativeWebView.postMessage(JSON.stringify(obj)); @@ -103,12 +103,12 @@ private val DIALOG_OVERRIDE_JS = """ true; """.trimIndent() -private fun buildInjectedJs(config: XWebViewConfig): String = +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. -private class XWebViewJsBridge( +internal class XWebViewJsBridge( private val mainHandler: Handler, private val onMessage: () -> ((String) -> Unit)?, ) {