Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ public enum class WebViewAction {

@SerializedName("asyncOperation")
ASYNC_OPERATION,

@SerializedName("openLink")
OPEN_LINK,

@SerializedName("navigationIntercepted")
NAVIGATION_INTERCEPTED,
}

@InternalMindboxApi
Expand Down Expand Up @@ -80,6 +86,8 @@ public sealed class BridgeMessage {
public companion object {
public const val VERSION: Int = 1
public const val EMPTY_PAYLOAD: String = "{}"
public const val SUCCESS_PAYLOAD: String = """{"success":true}"""
public const val UNKNOWN_ERROR_PAYLOAD: String = """{"error":"Unknown error"}"""
public const val TYPE_FIELD_NAME: String = "type"
public const val TYPE_REQUEST: String = "request"
public const val TYPE_RESPONSE: String = "response"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package cloud.mindbox.mobile_sdk.inapp.presentation.view

import android.app.Application
import android.net.Uri
import android.view.ViewGroup
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import cloud.mindbox.mobile_sdk.BuildConfig
import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi
Expand Down Expand Up @@ -41,6 +43,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.util.Locale
import java.util.Timer
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.timer
Expand All @@ -64,6 +67,7 @@ internal class WebViewInAppViewHolder(
private var closeInappTimer: Timer? = null
private var webViewController: WebViewController? = null
private var backPressedCallback: OnBackPressedCallback? = null
private var currentWebViewOrigin: String? = null
private val pendingResponsesById: MutableMap<String, CompletableDeferred<BridgeMessage.Response>> =
ConcurrentHashMap()

Expand All @@ -76,6 +80,9 @@ internal class WebViewInAppViewHolder(
private val operationExecutor: WebViewOperationExecutor by lazy {
MindboxWebViewOperationExecutor()
}
private val linkRouter: WebViewLinkRouter by lazy {
MindboxWebViewLinkRouter(appContext)
}

override val isActive: Boolean
get() = isInAppMessageActive
Expand Down Expand Up @@ -131,6 +138,7 @@ internal class WebViewInAppViewHolder(
register(WebViewAction.TOAST, ::handleToastAction)
register(WebViewAction.ALERT, ::handleAlertAction)
register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction)
register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction)
registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction)
register(WebViewAction.READY) {
handleReadyAction(
Expand Down Expand Up @@ -238,6 +246,14 @@ internal class WebViewInAppViewHolder(
return BridgeMessage.EMPTY_PAYLOAD
}

private fun handleOpenLinkAction(message: BridgeMessage.Request): String {
linkRouter.executeOpenLink(message.payload)
.getOrElse { error: Throwable ->
throw IllegalStateException(error.message ?: "Navigation error")
}
return BridgeMessage.SUCCESS_PAYLOAD
}

private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String {
return operationExecutor.executeSyncOperation(message.payload)
}
Expand All @@ -253,9 +269,14 @@ internal class WebViewInAppViewHolder(
controller.setEventListener(object : WebViewEventListener {
override fun onPageFinished(url: String?) {
mindboxLogD("onPageFinished: $url")
currentWebViewOrigin = resolveOrigin(url) ?: currentWebViewOrigin
webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript)
}

override fun onShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean {
return handleShouldOverrideUrlLoading(url = url, isForMainFrame = isForMainFrame)
}

override fun onError(error: WebViewError) {
mindboxLogE("WebView error: code=${error.code}, description=${error.description}, url=${error.url}")
if (error.isForMainFrame == true) {
Expand All @@ -270,6 +291,60 @@ internal class WebViewInAppViewHolder(
return controller
}

private fun handleShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean {
if (isForMainFrame != true) {
return false
}
if (shouldAllowLocalNavigation(url)) {
return false
}
val normalizedUrl: String = url?.trim().orEmpty()
sendNavigationInterceptedEvent(url = normalizedUrl)
return true
}

private fun sendNavigationInterceptedEvent(url: String) {
val controller: WebViewController = webViewController ?: return
val payload: String = gson.toJson(NavigationInterceptedPayload(url = url))
val message: BridgeMessage.Request = BridgeMessage.createAction(
action = WebViewAction.NAVIGATION_INTERCEPTED,
payload = payload
)
sendActionInternal(controller, message) { error ->
mindboxLogW("Failed to send navigationIntercepted event to WebView: $error")
}
}

private fun shouldAllowLocalNavigation(url: String?): Boolean {
if (url.isNullOrBlank()) {
return true
}
val normalizedUrl: String = url.trim()
if (normalizedUrl.startsWith("#")) {
return true
}
if (normalizedUrl.startsWith("about:blank")) {
return true
}
val targetOrigin: String = resolveOrigin(normalizedUrl) ?: return false
val sourceOrigin: String = currentWebViewOrigin ?: return false
return targetOrigin == sourceOrigin
}

private fun resolveOrigin(url: String?): String? {
if (url.isNullOrBlank()) {
return null
}
val parsedUri: Uri = runCatching { url.toUri() }.getOrNull() ?: return null
val scheme: String = parsedUri.scheme?.lowercase(Locale.US).orEmpty()
val host: String = parsedUri.host?.lowercase(Locale.US).orEmpty()
if (scheme.isBlank() || host.isBlank()) {
return null
}
val normalizedPort: String = if (parsedUri.port >= 0) ":${parsedUri.port}" else ""
return "$scheme://$host$normalizedPort"
}

private fun clearBackPressedCallback() {
backPressedCallback?.remove()
}
Expand Down Expand Up @@ -335,7 +410,12 @@ internal class WebViewInAppViewHolder(
error: Throwable,
controller: WebViewController,
) {
val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, error.message)
val json: String = runCatching {
val payload = ErrorPayload(error = requireNotNull(error.message))
gson.toJson(payload)
}.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD)

val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, json)
mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}")
sendActionInternal(controller, errorMessage)
}
Expand Down Expand Up @@ -401,6 +481,7 @@ internal class WebViewInAppViewHolder(
runCatching {
gatewayManager.fetchWebViewContent(contentUrl)
}.onSuccess { response: String ->
currentWebViewOrigin = resolveOrigin(layer.baseUrl)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Здесь не будет гонки? Присваиваем currentWebViewOrigin здесь и в onPageFinished

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

нет они друг за другом идут. Сначала мы выставляем тот, что грузим, а потом тот что загрузился. Потому что они могут отличаться, если был редирект

onContentPageLoaded(
content = WebViewHtmlContent(
baseUrl = layer.baseUrl ?: "",
Expand Down Expand Up @@ -549,8 +630,17 @@ internal class WebViewInAppViewHolder(
stopTimer()
cancelPendingResponses("WebView In-App is released")
clearBackPressedCallback()
currentWebViewOrigin = null
webViewController?.destroy()
webViewController = null
backPressedCallback = null
}

private data class NavigationInterceptedPayload(
val url: String
)

private data class ErrorPayload(
val error: String
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cloud.mindbox.mobile_sdk.inapp.presentation.view

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import cloud.mindbox.mobile_sdk.logger.mindboxLogW
import com.google.gson.JsonParser

internal interface WebViewLinkRouter {
fun executeOpenLink(request: String?): Result<String>
}

internal class MindboxWebViewLinkRouter(
private val context: Context,
) : WebViewLinkRouter {

companion object {
private const val SCHEME_HTTP = "http"
private const val SCHEME_HTTPS = "https"
private const val SCHEME_INTENT = "intent"
private const val SCHEME_TEL = "tel"
private const val SCHEME_MAILTO = "mailto"
private const val SCHEME_SMS = "sms"
private const val KEY_URL = "url"
private val BLOCKED_SCHEMES: Set<String> = setOf("javascript", "file", "data", "blob")
private const val ERROR_MISSING_URL = "Invalid payload: missing or empty 'url' field"
}

override fun executeOpenLink(request: String?): Result<String> {
return runCatching {
val url: String = extractTargetUrl(request)
val parsedUri = parseUrl(url)
routeByScheme(
parsedUri = parsedUri,
targetUrl = url,
)
}
}

private fun extractTargetUrl(request: String?): String {
if (request.isNullOrBlank()) {
throw IllegalStateException(ERROR_MISSING_URL)
}
val parsedJsonElement = runCatching { JsonParser.parseString(request) }.getOrNull()
?: throw IllegalStateException(ERROR_MISSING_URL)
if (!parsedJsonElement.isJsonObject) {
throw IllegalStateException(ERROR_MISSING_URL)
}
val url: String = parsedJsonElement.asJsonObject.get(KEY_URL)?.asString?.trim().orEmpty()
if (url.isBlank()) {
throw IllegalStateException(ERROR_MISSING_URL)
}
return url
}

private fun parseUrl(url: String): Uri {
val parsedUri: Uri = url.toUri()
val scheme: String = parsedUri.scheme?.lowercase().orEmpty()
if (scheme.isBlank()) {
throw IllegalStateException("Invalid URL: '$url' could not be parsed")
}
if (scheme in BLOCKED_SCHEMES) {
throw IllegalStateException("Blocked URL scheme: '$scheme'")
}
return parsedUri
}

private fun routeByScheme(
parsedUri: Uri,
targetUrl: String,
): String {
val scheme = parsedUri.scheme
requireNotNull(scheme) { "Url scheme must be not null" }
return when (scheme.lowercase()) {
SCHEME_INTENT -> openIntentUri(targetUrl)
SCHEME_TEL -> openDialLink(parsedUri, targetUrl)
SCHEME_SMS, SCHEME_MAILTO -> openSendToLink(parsedUri, targetUrl)
SCHEME_HTTP, SCHEME_HTTPS -> openUriWithViewIntent(parsedUri, targetUrl)
else -> openUriWithViewIntent(parsedUri, targetUrl)
}
}

private fun openIntentUri(rawIntentUri: String): String {
val parsedIntent: Intent = runCatching { Intent.parseUri(rawIntentUri, Intent.URI_INTENT_SCHEME) }
.getOrElse {
mindboxLogW("Intent URI parse failed: $rawIntentUri")
throw IllegalStateException("Invalid URL: '$rawIntentUri' could not be parsed")
}
if (parsedIntent.action.isNullOrBlank()) {
parsedIntent.action = Intent.ACTION_VIEW
}
parsedIntent.selector = null
parsedIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return startIntent(parsedIntent, rawIntentUri)
}

private fun openDialLink(uri: Uri, rawUrl: String): String {
val dialIntent: Intent = Intent(Intent.ACTION_DIAL, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return startIntent(dialIntent, rawUrl)
}

private fun openSendToLink(uri: Uri, rawUrl: String): String {
val smsIntent: Intent = Intent(Intent.ACTION_SENDTO, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return startIntent(smsIntent, rawUrl)
}

private fun openUriWithViewIntent(uri: Uri, rawUrl: String): String {
val intent: Intent = Intent(Intent.ACTION_VIEW, uri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return startIntent(intent, rawUrl)
}

private fun startIntent(intent: Intent, rawUrl: String): String {
return try {
context.startActivity(intent)
rawUrl
} catch (error: ActivityNotFoundException) {
mindboxLogW("Activity not found for URI: $rawUrl")
throw IllegalStateException(
"ActivityNotFoundException: ${error.message ?: "No activity found to handle URL"}"
)
} catch (error: SecurityException) {
mindboxLogW("Security exception for URI: $rawUrl")
throw IllegalStateException(
"SecurityException: ${error.message ?: "Cannot open URL"}"
)
} catch (error: Throwable) {
throw IllegalStateException(error.message ?: "Navigation failed: unable to open URL")
}
}
}
Loading