{"id":1276,"date":"2026-05-31T17:00:00","date_gmt":"2026-05-31T22:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1276"},"modified":"2026-03-07T03:49:03","modified_gmt":"2026-03-07T08:49:03","slug":"android-webview-app-links","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/android-webview-app-links\/","title":{"rendered":"Android WebView and App Links: Handling Deep Links"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">WebView is Android&#39;s embedded browser component. It renders web content inside your app, which is useful for displaying help pages, terms of service, web-based features, and hybrid app content. But when that web content contains deep links (App Links, custom schemes, or intent URLs), WebView&#39;s default behavior is almost always wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">By default, WebView loads everything internally. A tap on an App Link loads the target page inside the WebView instead of opening the target app. A tap on a <code>tel:<\/code> link shows an error instead of opening the dialer. A tap on a <code>mailto:<\/code> link does nothing useful. Every link type that should leave the WebView stays trapped inside it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide covers how to configure WebView to handle deep links correctly. For Chrome Custom Tabs (the alternative to WebView for in-app browsing), see the <a href=\"https:\/\/tolinku.com\/blog\/chrome-custom-tabs-app-links\/\">Custom Tabs guide<\/a>. For the App Links setup, see the <a href=\"https:\/\/tolinku.com\/blog\/android-app-links-complete-guide\/\">Android App Links complete guide<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Core Problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a user taps a link inside a WebView, the WebView&#39;s <code>WebViewClient<\/code> decides what to do. The default behavior:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">\/\/ Default WebView behavior (simplified)\nfun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {\n    return false \/\/ Load everything inside the WebView\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Returning <code>false<\/code> means &quot;handle this URL internally.&quot; The WebView loads the page, even if that URL is an App Link that should open another app, a phone number, or a deep link back into your own native navigation.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring <code>shouldOverrideUrlLoading<\/code><\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Basic Configuration<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Override <code>shouldOverrideUrlLoading<\/code> to intercept navigation and route URLs appropriately:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">webView.webViewClient = object : WebViewClient() {\n\n    override fun shouldOverrideUrlLoading(\n        view: WebView,\n        request: WebResourceRequest\n    ): Boolean {\n        val url = request.url\n        val scheme = url.scheme\n\n        return when {\n            \/\/ Handle custom schemes (tel:, mailto:, etc.)\n            scheme == &quot;tel&quot; || scheme == &quot;mailto&quot; || scheme == &quot;sms&quot; -&gt; {\n                val intent = Intent(Intent.ACTION_VIEW, url)\n                try {\n                    startActivity(intent)\n                } catch (e: ActivityNotFoundException) {\n                    \/\/ No handler for this scheme\n                }\n                true \/\/ We handled it; don&#39;t load in WebView\n            }\n\n            \/\/ Handle intent:\/\/ URLs\n            scheme == &quot;intent&quot; -&gt; {\n                handleIntentUrl(url.toString())\n                true\n            }\n\n            \/\/ Handle deep links to your own app\n            isOwnAppLink(url) -&gt; {\n                handleOwnDeepLink(url)\n                true\n            }\n\n            \/\/ Handle external App Links (open in browser\/target app)\n            isExternalLink(url) -&gt; {\n                val intent = Intent(Intent.ACTION_VIEW, url)\n                startActivity(intent)\n                true\n            }\n\n            \/\/ Load everything else in the WebView\n            else -&gt; false\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Handling Your Own Deep Links<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When web content inside your WebView contains links back to your own app (e.g., a link from your help center to a product page), you should handle those natively rather than loading them in the WebView:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">fun isOwnAppLink(url: Uri): Boolean {\n    val ownHosts = setOf(&quot;yourapp.com&quot;, &quot;links.yourapp.com&quot;, &quot;go.yourapp.com&quot;)\n    return url.scheme == &quot;https&quot; &amp;&amp; url.host in ownHosts\n}\n\nfun handleOwnDeepLink(url: Uri) {\n    \/\/ Route to native navigation\n    when {\n        url.path?.startsWith(&quot;\/products&quot;) == true -&gt; {\n            val productId = url.lastPathSegment\n            startActivity(ProductActivity.intent(this, productId))\n        }\n        url.path?.startsWith(&quot;\/settings&quot;) == true -&gt; {\n            startActivity(SettingsActivity.intent(this))\n        }\n        else -&gt; {\n            \/\/ Unknown path; open externally\n            startActivity(Intent(Intent.ACTION_VIEW, url))\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Handling Intent URLs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Intent URLs (<code>intent:\/\/...<\/code>) are a common mechanism for web-to-app deep linking on Android. WebView doesn&#39;t handle them by default:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">fun handleIntentUrl(url: String) {\n    try {\n        val intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)\n\n        \/\/ Security: verify the intent targets a browsable activity\n        intent.addCategory(Intent.CATEGORY_BROWSABLE)\n        intent.component = null\n        intent.selector = null\n\n        startActivity(intent)\n    } catch (e: Exception) {\n        \/\/ Parse failed or no handler; try the fallback URL\n        val fallbackUrl = Uri.parse(url).getQueryParameter(&quot;S.browser_fallback_url&quot;)\n        if (fallbackUrl != null) {\n            webView.loadUrl(fallbackUrl)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Handling External Links<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For links that point to other domains (not your own), decide whether to open them in the WebView, in Chrome Custom Tabs, or via the system browser:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">fun isExternalLink(url: Uri): Boolean {\n    val ownHosts = setOf(&quot;yourapp.com&quot;, &quot;links.yourapp.com&quot;, &quot;help.yourapp.com&quot;)\n    return url.scheme == &quot;https&quot; &amp;&amp; url.host !in ownHosts\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For external links, opening them in the default browser (via <code>Intent.ACTION_VIEW<\/code>) is usually the right choice. This lets App Links work correctly: if the link targets another app, that app will open. If it&#39;s a regular web URL, Chrome (or the default browser) handles it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common Pitfalls<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Pitfall 1: Infinite Loop Between WebView and App<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If your WebView loads a page from your own domain, and that page contains links to your own domain, and <code>shouldOverrideUrlLoading<\/code> catches those links and routes them to your native navigation, you might end up in a loop:<\/p>\n\n\n\n<pre><code>WebView loads help.yourapp.com\n  \u2192 User taps link to yourapp.com\/products\/123\n  \u2192 shouldOverrideUrlLoading catches it\n  \u2192 Opens ProductActivity\n  \u2192 ProductActivity has a WebView showing yourapp.com\/products\/123\n  \u2192 That page has links to yourapp.com\/...\n  \u2192 Loop continues\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fix:<\/strong> Distinguish between &quot;links that should navigate natively&quot; and &quot;links that should load in the current WebView.&quot; Use path-based rules:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {\n    val url = request.url\n\n    \/\/ Links within the help center: stay in WebView\n    if (url.host == &quot;help.yourapp.com&quot;) return false\n\n    \/\/ Deep links to product pages: navigate natively\n    if (url.host == &quot;yourapp.com&quot; &amp;&amp; url.path?.startsWith(&quot;\/products&quot;) == true) {\n        handleOwnDeepLink(url)\n        return true\n    }\n\n    \/\/ Everything else: stay in WebView\n    return false\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Pitfall 2: JavaScript-Triggered Navigation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>shouldOverrideUrlLoading<\/code> is NOT called for all navigation events. Notably:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>window.location<\/code> changes from JavaScript may not trigger it consistently.<\/li>\n<li>Form submissions may not trigger it.<\/li>\n<li>Redirects (302) may not trigger it on all Android versions.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For JavaScript-initiated navigation, also override <code>onPageStarted<\/code>:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {\n    val uri = Uri.parse(url)\n\n    \/\/ Catch redirects to external domains\n    if (isExternalLink(uri)) {\n        view.stopLoading()\n        startActivity(Intent(Intent.ACTION_VIEW, uri))\n        return\n    }\n\n    super.onPageStarted(view, url, favicon)\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Pitfall 3: Missing WebViewClient<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you don&#39;t set a <code>WebViewClient<\/code> at all, the WebView opens ALL links in the system browser. This is the opposite problem: nothing loads in the WebView.<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">\/\/ Without this, every link opens externally\nwebView.webViewClient = WebViewClient() \/\/ At minimum, set the default client\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Pitfall 4: POST Requests<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>shouldOverrideUrlLoading<\/code> only fires for GET requests (user-initiated navigation). POST requests (form submissions) are handled internally by the WebView. If a form submission redirects to a deep link URL, you need to catch it in <code>onPageStarted<\/code> or <code>doUpdateVisitedHistory<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Pitfall 5: SSL Errors in WebView<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If your deep link landing page has SSL issues (expired certificate, mismatched domain), the WebView shows a blank page with no error indication. This is different from Chrome, which shows a full-page warning.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Always handle SSL errors:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {\n    \/\/ Don&#39;t call handler.proceed() in production; that bypasses SSL verification\n    handler.cancel()\n    showSslErrorMessage()\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">WebView vs. Chrome Custom Tabs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For most &quot;open a link inside the app&quot; use cases, <a href=\"https:\/\/tolinku.com\/blog\/chrome-custom-tabs-app-links\/\">Chrome Custom Tabs<\/a> are better than WebView:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Feature<\/th>\n<th>WebView<\/th>\n<th>Chrome Custom Tabs<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td>App Link support<\/td>\n<td>Manual (via shouldOverrideUrlLoading)<\/td>\n<td>Automatic<\/td>\n<\/tr>\n<tr>\n<td>Password autofill<\/td>\n<td>No<\/td>\n<td>Yes (Chrome&#39;s password manager)<\/td>\n<\/tr>\n<tr>\n<td>Cookie sharing with Chrome<\/td>\n<td>No<\/td>\n<td>Yes<\/td>\n<\/tr>\n<tr>\n<td>Performance<\/td>\n<td>Slower (separate rendering engine instance)<\/td>\n<td>Faster (shares Chrome&#39;s process)<\/td>\n<\/tr>\n<tr>\n<td>Security updates<\/td>\n<td>Depends on system WebView version<\/td>\n<td>Updated with Chrome<\/td>\n<\/tr>\n<tr>\n<td>Deep link handling<\/td>\n<td>Manual configuration required<\/td>\n<td>Mostly automatic<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use WebView when:<\/strong> You need full control over the rendered content (hybrid app features, custom JavaScript bridges, inline content).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Use Custom Tabs when:<\/strong> You&#39;re opening external or semi-external web content (help pages, OAuth flows, partner pages).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Integration with Tolinku<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When your WebView loads pages that contain <a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku deep links<\/a>, ensure <code>shouldOverrideUrlLoading<\/code> handles the Tolinku redirect chain correctly:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {\n    val url = request.url\n    val host = url.host ?: &quot;&quot;\n\n    \/\/ Let Tolinku links open externally (they redirect to the target app)\n    if (host.endsWith(&quot;tolinku.com&quot;) || host.endsWith(&quot;tolk.link&quot;)) {\n        startActivity(Intent(Intent.ACTION_VIEW, url))\n        return true\n    }\n\n    \/\/ ... rest of your routing logic\n    return false\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This ensures Tolinku&#39;s platform routing (app detection, deferred deep linking, fallback handling) works correctly rather than being trapped inside the WebView.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the full App Links setup, see the <a href=\"https:\/\/tolinku.com\/blog\/android-app-links-complete-guide\/\">Android App Links guide<\/a>. For the <a href=\"https:\/\/tolinku.com\/docs\/developer\/app-links\/\">Tolinku SDK<\/a> integration, the SDK handles deep link routing automatically in native Activities.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Handle deep links correctly in Android WebView. Configure shouldOverrideUrlLoading, manage navigation between WebView and native, and avoid common traps.<\/p>\n","protected":false},"author":2,"featured_media":1275,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Android WebView and App Links: Handling Deep Links","rank_math_description":"Handle deep links correctly in Android WebView. Configure shouldOverrideUrlLoading, manage navigation, and avoid common WebView traps.","rank_math_focus_keyword":"android webview app links","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-android-webview-app-links.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-android-webview-app-links.png","footnotes":""},"categories":[10],"tags":[25,23,20,315,69,83,325],"class_list":["post-1276","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-android","tag-android","tag-app-links","tag-deep-linking","tag-in-app-browser","tag-mobile-development","tag-navigation","tag-webview"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1276","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=1276"}],"version-history":[{"count":3,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1276\/revisions"}],"predecessor-version":[{"id":2576,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1276\/revisions\/2576"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1275"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1276"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1276"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1276"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}