diff --git a/kmp-common-sdk b/kmp-common-sdk index 7d0a4699..80e199ff 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 7d0a469952c5291af01ded0cef9204aa1a5c466d +Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt index a7c423ee..d9f718f9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewAction.kt @@ -39,6 +39,12 @@ public enum class WebViewAction { @SerializedName("asyncOperation") ASYNC_OPERATION, + + @SerializedName("openLink") + OPEN_LINK, + + @SerializedName("navigationIntercepted") + NAVIGATION_INTERCEPTED, } @InternalMindboxApi @@ -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" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7992f238..cd5fd26d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -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 @@ -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 @@ -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> = ConcurrentHashMap() @@ -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 @@ -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( @@ -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) } @@ -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) { @@ -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() } @@ -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) } @@ -401,6 +481,7 @@ internal class WebViewInAppViewHolder( runCatching { gatewayManager.fetchWebViewContent(contentUrl) }.onSuccess { response: String -> + currentWebViewOrigin = resolveOrigin(layer.baseUrl) onContentPageLoaded( content = WebViewHtmlContent( baseUrl = layer.baseUrl ?: "", @@ -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 + ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt new file mode 100644 index 00000000..26cbb475 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouter.kt @@ -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 +} + +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 = setOf("javascript", "file", "data", "blob") + private const val ERROR_MISSING_URL = "Invalid payload: missing or empty 'url' field" + } + + override fun executeOpenLink(request: String?): Result { + 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") + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt new file mode 100644 index 00000000..beff15ff --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewLinkRouterTest.kt @@ -0,0 +1,257 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +internal class WebViewLinkRouterTest { + + private lateinit var context: Context + private lateinit var router: MindboxWebViewLinkRouter + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + router = MindboxWebViewLinkRouter(context) + } + + @Test + fun `executeOpenLink opens web links from pdf cases`() { + registerBrowsableHandler("https") + val inputUrls: List = listOf( + "https://www.google.com", + "https://habr.com/ru/articles/", + "https://test-site.g.mindbox.ru", + "https://test-site.g.mindbox.ru/some/path?param=1", + "https://mindbox.ru", + "https://mindbox.ru/products", + "https://www.youtube.com/watch?v=abc", + "https://t.me/durov", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens deeplink schemes from pdf cases`() { + registerBrowsableHandler("pushok") + val inputUrls: List = listOf( + "pushok://", + "pushok://product/123", + "pushok://catalog?category=shoes&sort=price", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens intent uri`() { + registerBrowsableHandler("myapp") + val intentUrl: String = + "intent://catalog/item/1#Intent;scheme=myapp;S.browser_fallback_url=https%3A%2F%2Fmindbox.ru;end" + val result: Result = router.executeOpenLink("""{"url":"$intentUrl"}""") + assertTrue(result.isSuccess) + assertEquals(intentUrl, result.getOrNull()) + } + + @Test + fun `executeOpenLink opens tg deeplink when handler exists`() { + registerBrowsableHandler("tg") + val inputUrl: String = "tg://resolve?domain=durov" + val result: Result = executeOpenLink(url = inputUrl) + assertTrue(result.isSuccess) + assertEquals(inputUrl, result.getOrNull()) + } + + @Test + fun `executeOpenLink returns error for tg deeplink when handler missing`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val result: Result = activityNotFoundRouter.executeOpenLink("""{"url":"tg://resolve?domain=durov"}""") + assertFalse(result.isSuccess) + assertErrorContains(result = result, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink opens system schemes from pdf cases`() { + registerActionHandler(action = Intent.ACTION_DIAL, scheme = "tel") + registerActionHandler(action = Intent.ACTION_SENDTO, scheme = "mailto") + registerActionHandler(action = Intent.ACTION_SENDTO, scheme = "sms") + val inputUrls: List = listOf( + "tel:+1234567890", + "mailto:test@example.com", + "sms:+1234567890", + ) + inputUrls.forEach { inputUrl: String -> + val actualResult: Result = executeOpenLink(url = inputUrl) + assertTrue(actualResult.isSuccess) + assertEquals(inputUrl, actualResult.getOrNull()) + } + } + + @Test + fun `executeOpenLink opens android only schemes when handler exists`() { + registerBrowsableHandler("geo") + registerBrowsableHandler("market") + val geoResult: Result = executeOpenLink(url = "geo:55.7558,37.6173?q=Moscow") + assertTrue(geoResult.isSuccess) + assertEquals("geo:55.7558,37.6173?q=Moscow", geoResult.getOrNull()) + val marketResult: Result = executeOpenLink(url = "market://details?id=com.google.android.gm") + assertTrue(marketResult.isSuccess) + assertEquals("market://details?id=com.google.android.gm", marketResult.getOrNull()) + } + + @Test + fun `executeOpenLink returns error for iOS only schemes without handler`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val mapsResult: Result = activityNotFoundRouter.executeOpenLink("""{"url":"maps://?q=Moscow"}""") + val appStoreResult: Result = + activityNotFoundRouter.executeOpenLink("""{"url":"itms-apps://apps.apple.com/app/id389801252"}""") + assertFalse(mapsResult.isSuccess) + assertFalse(appStoreResult.isSuccess) + assertErrorContains(result = mapsResult, expectedMessagePart = "ActivityNotFoundException") + assertErrorContains(result = appStoreResult, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink returns error for blocked schemes from pdf cases`() { + val blockedUrls: List = listOf( + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,

blocked

", + "blob:https://example.com/uuid", + ) + blockedUrls.forEach { blockedUrl: String -> + val actualResult: Result = executeOpenLink(url = blockedUrl) + assertFalse(actualResult.isSuccess) + assertErrorContains(result = actualResult, expectedMessagePart = "Blocked URL scheme") + } + } + + @Test + fun `executeOpenLink returns error for invalid or missing scheme urls`() { + val invalidResult: Result = executeOpenLink(url = "not a url at all") + val missingSchemeResult: Result = executeOpenLink(url = "://missing-scheme") + assertFalse(invalidResult.isSuccess) + assertFalse(missingSchemeResult.isSuccess) + assertErrorContains(result = invalidResult, expectedMessagePart = "Invalid URL") + assertErrorContains(result = missingSchemeResult, expectedMessagePart = "Invalid URL") + } + + @Test + fun `executeOpenLink returns error for unknown scheme without activity`() { + val activityNotFoundRouter: MindboxWebViewLinkRouter = createRouterWithActivityNotFoundError() + val result: Result = activityNotFoundRouter.executeOpenLink("""{"url":"nonexistent-scheme://test"}""") + assertFalse(result.isSuccess) + assertErrorContains(result = result, expectedMessagePart = "ActivityNotFoundException") + } + + @Test + fun `executeOpenLink returns error for invalid payload cases from pdf`() { + val nullPayloadResult: Result = router.executeOpenLink(null) + val emptyPayloadResult: Result = router.executeOpenLink("") + val blankPayloadResult: Result = router.executeOpenLink(" ") + val missingUrlResult: Result = router.executeOpenLink("""{"foo":"bar"}""") + val emptyUrlResult: Result = router.executeOpenLink("""{"url":""}""") + val invalidJsonResult: Result = router.executeOpenLink("""{not-json}""") + val notObjectJsonResult: Result = router.executeOpenLink("""["https://mindbox.ru"]""") + val payloadResults: List> = listOf( + nullPayloadResult, + emptyPayloadResult, + blankPayloadResult, + missingUrlResult, + emptyUrlResult, + invalidJsonResult, + notObjectJsonResult, + ) + payloadResults.forEach { actualResult: Result -> + assertFalse(actualResult.isSuccess) + assertErrorContains( + result = actualResult, + expectedMessagePart = "Invalid payload: missing or empty 'url' field", + ) + } + } + + private fun executeOpenLink(url: String): Result { + return router.executeOpenLink("""{"url":"$url"}""") + } + + private fun assertErrorContains( + result: Result, + expectedMessagePart: String, + ) { + val actualError: Throwable? = result.exceptionOrNull() + assertNotNull(actualError) + val actualMessage: String = actualError?.message.orEmpty() + assertTrue(actualMessage.contains(expectedMessagePart)) + } + + private fun createRouterWithActivityNotFoundError(): MindboxWebViewLinkRouter { + val wrappedContext: Context = object : ContextWrapper(context) { + override fun startActivity(intent: Intent) { + throw ActivityNotFoundException("No activity found") + } + } + return MindboxWebViewLinkRouter(wrappedContext) + } + + private fun registerBrowsableHandler(scheme: String) { + registerHandler( + action = Intent.ACTION_VIEW, + scheme = scheme, + isBrowsable = true, + ) + } + + private fun registerActionHandler( + action: String, + scheme: String, + ) { + registerHandler( + action = action, + scheme = scheme, + isBrowsable = false, + ) + } + + private fun registerHandler( + action: String, + scheme: String, + isBrowsable: Boolean, + ) { + val componentName: ComponentName = ComponentName("com.example", "TestActivityFor_${action}_$scheme") + val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) + packageManager.addActivityIfNotPresent(componentName) + packageManager.addIntentFilterForActivity( + componentName, + IntentFilter(action).apply { + addCategory(Intent.CATEGORY_DEFAULT) + if (isBrowsable) { + addCategory(Intent.CATEGORY_BROWSABLE) + } + addDataScheme(scheme) + } + ) + } +}