You tapped a phone number or a deep link inside your app's WebView and got hit with ERR_UNKNOWN_URL_SCHEME instead of the dialer or the app opening. Annoying, but the cause is boringly predictable.
The fix
WebView doesn't know how to handle anything that isn't http, https, file, data, or about. Every other scheme — tel:, mailto:, sms:, intent:, market:, whatsapp:, myapp: — has to be intercepted and routed by you.
Override shouldOverrideUrlLoading in your WebViewClient and hand non-HTTP schemes to the system via an Intent:
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url
val scheme = url.scheme?.lowercase()
if (scheme == "http" || scheme == "https") {
return false // let WebView handle it
}
return try {
val intent = Intent(Intent.ACTION_VIEW, url)
view.context.startActivity(intent)
true // we handled it
} catch (e: ActivityNotFoundException) {
false
}
}
}
If you're stuck on the older deprecated overload, it looks like this:
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith("http://") || url.startsWith("https://")) return false
return try {
view.context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
true
} catch (e: ActivityNotFoundException) {
false
}
}
Return true when you've taken over the URL, false to let WebView keep doing its thing. Get that boolean backwards and either nothing happens or you get the same error again.
Why this works
What's actually happening: WebView is a rendering engine, not an OS router. When it encounters tel:+15551234567, it has no idea that means "open the dialer." It only knows how to render documents it already understands. Everything else gets bounced back as ERR_UNKNOWN_URL_SCHEME.
The reason step 3 works is that Intent.ACTION_VIEW with a URI hands the URL to Android's Intent resolution system — the same mechanism that fires when you tap a link in Chrome. The system checks which installed apps registered an intent-filter for that scheme and launches the right one. Your job in shouldOverrideUrlLoading is just to make that handoff. The WebView stops trying to load the URL itself once you return true.
Watch out for intent:// URLs
Chrome-generated links often use the intent:// scheme, which looks like this:
intent://scan/#Intent;scheme=zxing;package=com.google.zxing.client.android;end
You can't just fire that at Intent.ACTION_VIEW — you'll get ActivityNotFoundException on most devices. Parse it with Intent.parseUri:
if (url.scheme == "intent") {
val intent = Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME)
intent.addCategory(Intent.CATEGORY_BROWSABLE)
intent.component = null
try {
view.context.startActivity(intent)
return true
} catch (e: ActivityNotFoundException) {
val fallback = intent.getStringExtra("browser_fallback_url")
if (fallback != null) {
view.loadUrl(fallback)
}
return true
}
}
Setting component = null is the trick that most tutorials miss. It strips the package hint from the parsed URI so the intent can actually resolve on devices where that exact package name doesn't match.
Less common variations
Your own custom scheme isn't firing
If myapp://orders/123 does nothing, check three things. First, the intent-filter in your AndroidManifest.xml needs both BROWSABLE and DEFAULT categories:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="orders" />
</intent-filter>
Without BROWSABLE, intents launched from a WebView (which is a browser context) won't match. Second, Android 11+ requires you to declare visibility for the app you're trying to launch if it's not yours. Add <queries> entries or you'll silently fail. Third, on Android 12+, android:exported must be explicit on any activity with an intent-filter, or the app won't install on Play.
tel: works on the emulator but fails on a real device
Emulators usually lack a dialer. Wrap your startActivity in a resolveActivity check:
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("tel:$number"))
if (intent.resolveActivity(view.context.packageManager) != null) {
view.context.startActivity(intent)
} else {
Toast.makeText(view.context, "No app for this link", Toast.LENGTH_SHORT).show()
}
The ActivityNotFoundException isn't always thrown on some older Android versions — resolveActivity is the safe belt-and-suspenders move.
API 24+ filtering trap
On API 24 and up, you can't rely on the URL string overload being called at all for some redirects. The WebResourceRequest overload fires more reliably and gives you isRedirect, hasGesture, and request headers. If your handler isn't running, you're probably on the deprecated signature and a redirect is bypassing it.
Prevention
Set up the scheme check the moment you create the WebView, not after QA reports it. Bake it into a base WebViewClient so every screen inherits it. Test with real links — a tel: link, a mailto:, a market://details?id=... Play Store link, and one intent:// URL from Chrome. Those four cover 95% of what you'll see in the wild.
Don't disable WebView's default navigation by blanket-returning true — you'll break every regular link. Distinguish http/https from everything else and only intercept what WebView can't handle itself.