{"id":562,"date":"2026-03-21T13:00:00","date_gmt":"2026-03-21T18:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=562"},"modified":"2026-03-07T04:46:02","modified_gmt":"2026-03-07T09:46:02","slug":"kotlin-deep-link-handling","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/kotlin-deep-link-handling\/","title":{"rendered":"Kotlin Deep Link Handling: Modern Android Patterns"},"content":{"rendered":"\n<p>Deep link handling in Android has evolved considerably as Kotlin became the standard language and Jetpack components matured. Code that worked in 2019 with Java and manual intent parsing is now better expressed with sealed classes, the Navigation component, and coroutines. This article covers the patterns that work well in 2026 for production Android apps.<\/p>\n\n\n\n<p>The examples assume you have App Links verification set up (see our <a href=\"https:\/\/tolinku.com\/docs\/developer\/app-links\/\">Android App Links developer documentation<\/a> if not). The focus here is on what happens after Android routes a URL to your app.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Modeling Routes with Sealed Classes<\/h2>\n\n\n\n<p>The first improvement over ad-hoc string parsing is representing your routes as a type hierarchy. Sealed classes give you exhaustive pattern matching and make the compiler your ally when you add new routes.<\/p>\n\n\n\n<p>Define a sealed class for all deep link destinations in your app:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">sealed class DeepLinkRoute {\n    data class ProductDetail(val productId: String) : DeepLinkRoute()\n    data class OrderConfirmation(val orderId: String) : DeepLinkRoute()\n    data class UserProfile(val userId: String) : DeepLinkRoute()\n    data class Checkout(val cartToken: String, val promo: String?) : DeepLinkRoute()\n    object Home : DeepLinkRoute()\n    data class Unknown(val uri: Uri) : DeepLinkRoute()\n}\n<\/code><\/pre>\n\n\n\n<p>Then write a parser that converts a <code>Uri<\/code> to one of these types:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">object DeepLinkParser {\n\n    fun parse(uri: Uri): DeepLinkRoute {\n        if (uri.scheme != &quot;https&quot;) return DeepLinkRoute.Unknown(uri)\n        if (uri.host != &quot;yourdomain.com&quot;) return DeepLinkRoute.Unknown(uri)\n\n        val segments = uri.pathSegments\n\n        return when {\n            segments.size == 2 &amp;&amp; segments[0] == &quot;products&quot; -&gt; {\n                DeepLinkRoute.ProductDetail(productId = segments[1])\n            }\n            segments.size == 2 &amp;&amp; segments[0] == &quot;orders&quot; -&gt; {\n                DeepLinkRoute.OrderConfirmation(orderId = segments[1])\n            }\n            segments.size == 2 &amp;&amp; segments[0] == &quot;users&quot; -&gt; {\n                DeepLinkRoute.UserProfile(userId = segments[1])\n            }\n            segments.size == 1 &amp;&amp; segments[0] == &quot;checkout&quot; -&gt; {\n                val token = uri.getQueryParameter(&quot;token&quot;) ?: return DeepLinkRoute.Unknown(uri)\n                val promo = uri.getQueryParameter(&quot;promo&quot;)\n                DeepLinkRoute.Checkout(cartToken = token, promo = promo)\n            }\n            segments.isEmpty() -&gt; DeepLinkRoute.Home\n            else -&gt; DeepLinkRoute.Unknown(uri)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>The <code>Unknown<\/code> case is important. Do not crash or silently fail on unrecognized URLs. Log them, handle them gracefully, and route the user somewhere sensible (usually Home or an error screen).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Deep Links in an Activity<\/h2>\n\n\n\n<p>For apps with a single Activity, the entry point for deep links is <code>onCreate<\/code> and <code>onNewIntent<\/code>. Both must be handled because:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>onCreate<\/code> fires when the app is launched fresh from a link click<\/li>\n<li><code>onNewIntent<\/code> fires when the app is already running and a new link is clicked (with <code>launchMode=&quot;singleTask&quot;<\/code> or <code>singleTop<\/code>)<\/li>\n<\/ul>\n\n\n\n<pre><code class=\"language-kotlin\">class MainActivity : AppCompatActivity() {\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        setContentView(binding.root)\n\n        intent?.data?.let { uri -&gt;\n            handleDeepLink(uri)\n        }\n    }\n\n    override fun onNewIntent(intent: Intent) {\n        super.onNewIntent(intent)\n        intent.data?.let { uri -&gt;\n            handleDeepLink(uri)\n        }\n    }\n\n    private fun handleDeepLink(uri: Uri) {\n        val route = DeepLinkParser.parse(uri)\n        navigateTo(route)\n    }\n\n    private fun navigateTo(route: DeepLinkRoute) {\n        when (route) {\n            is DeepLinkRoute.ProductDetail -&gt;\n                navController.navigate(\n                    ProductDetailFragmentDirections.actionToProductDetail(route.productId)\n                )\n            is DeepLinkRoute.OrderConfirmation -&gt;\n                navController.navigate(\n                    OrderFragmentDirections.actionToOrderConfirmation(route.orderId)\n                )\n            is DeepLinkRoute.UserProfile -&gt;\n                navController.navigate(\n                    ProfileFragmentDirections.actionToUserProfile(route.userId)\n                )\n            is DeepLinkRoute.Checkout -&gt;\n                navController.navigate(\n                    CheckoutFragmentDirections.actionToCheckout(\n                        route.cartToken,\n                        route.promo\n                    )\n                )\n            is DeepLinkRoute.Home -&gt;\n                navController.navigate(R.id.homeFragment)\n            is DeepLinkRoute.Unknown -&gt; {\n                Log.w(&quot;DeepLink&quot;, &quot;Unrecognized URI: ${route.uri}&quot;)\n                navController.navigate(R.id.homeFragment)\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>The <code>when<\/code> expression on a sealed class is exhaustive: the compiler warns if you miss a branch. As you add new route types to the sealed class, you will get a compile-time reminder to add handling everywhere you use <code>when<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Async Deep Link Handling with Coroutines<\/h2>\n\n\n\n<p>Some deep links require async operations before navigation. For example, a shared content link might need to fetch the content first to determine if the user has access, or a referral link might need to record attribution before navigating.<\/p>\n\n\n\n<p>Use a ViewModel to encapsulate this logic:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class MainViewModel : ViewModel() {\n\n    private val _navigationEvent = MutableSharedFlow&lt;DeepLinkRoute&gt;()\n    val navigationEvent: SharedFlow&lt;DeepLinkRoute&gt; = _navigationEvent.asSharedFlow()\n\n    fun processDeepLink(uri: Uri) {\n        viewModelScope.launch {\n            val route = DeepLinkParser.parse(uri)\n            val resolvedRoute = resolveRoute(route)\n            _navigationEvent.emit(resolvedRoute)\n        }\n    }\n\n    private suspend fun resolveRoute(route: DeepLinkRoute): DeepLinkRoute {\n        return when (route) {\n            is DeepLinkRoute.ProductDetail -&gt; {\n                \/\/ Check if product exists and user has access\n                val product = productRepository.getProduct(route.productId)\n                if (product == null) DeepLinkRoute.Home else route\n            }\n            is DeepLinkRoute.Checkout -&gt; {\n                \/\/ Validate cart token before navigating\n                val isValid = cartRepository.validateToken(route.cartToken)\n                if (!isValid) DeepLinkRoute.Home else route\n            }\n            else -&gt; route\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>In your Activity, collect the navigation events:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun onCreate(savedInstanceState: Bundle?) {\n    super.onCreate(savedInstanceState)\n\n    lifecycleScope.launch {\n        viewModel.navigationEvent\n            .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)\n            .collect { route -&gt;\n                navigateTo(route)\n            }\n    }\n\n    intent?.data?.let { uri -&gt;\n        viewModel.processDeepLink(uri)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Using <code>SharedFlow<\/code> rather than <code>LiveData<\/code> for navigation events avoids the &quot;re-navigate on rotation&quot; problem. <code>SharedFlow<\/code> with <code>replay = 0<\/code> (the default) only emits to active collectors and does not replay the last event to new subscribers.<\/p>\n\n\n\n<p>The <code>flowWithLifecycle<\/code> operator ensures you do not process navigation events when the Activity is in the background, which prevents navigation calls on a stopped Activity.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Deep Links in Fragments<\/h2>\n\n\n\n<p>If your deep link routing needs to happen at the Fragment level (for example, in a multi-Activity app, or when a specific Fragment needs to handle links directly), the same principles apply but with Fragment lifecycle awareness.<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class ProductFragment : Fragment() {\n\n    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {\n        super.onViewCreated(view, savedInstanceState)\n\n        \/\/ Handle deep link argument passed via Navigation component\n        val productId = arguments?.getString(&quot;productId&quot;)\n            ?: requireActivity().intent?.data?.lastPathSegment\n\n        productId?.let {\n            viewModel.loadProduct(it)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>When using the Navigation component, prefer passing data through Navigation&#39;s argument system rather than reading the raw Intent URI in Fragments. This keeps Fragments unaware of the URL structure and makes them testable without an Intent. For Navigation component deep link integration, see <a href=\"https:\/\/tolinku.com\/blog\/jetpack-navigation-deep-links\/\">Jetpack Navigation Deep Links<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Back Stack Considerations<\/h2>\n\n\n\n<p>When a user arrives via a deep link, the back stack matters. If the user presses Back after arriving at a product detail screen via a deep link, they should not land in an empty app or exit entirely. They should arrive at a logical parent screen.<\/p>\n\n\n\n<p>For Navigation component users, define the back stack in the nav graph:<\/p>\n\n\n\n<pre><code class=\"language-xml\">&lt;fragment\n    android:id=&quot;@+id\/productDetailFragment&quot;\n    android:name=&quot;com.example.ProductDetailFragment&quot;&gt;\n    &lt;deepLink\n        android:id=&quot;@+id\/deepLink&quot;\n        app:uri=&quot;https:\/\/yourdomain.com\/products\/{productId}&quot; \/&gt;\n    &lt;argument\n        android:name=&quot;productId&quot;\n        app:argType=&quot;string&quot; \/&gt;\n&lt;\/fragment&gt;\n<\/code><\/pre>\n\n\n\n<p>When the Navigation component handles the deep link, it constructs the back stack defined by your nav graph. The user can press Back and navigate to the home destination rather than exiting.<\/p>\n\n\n\n<p>For deep links that should not add to the back stack (such as password reset links where you want the user to land on a clean state), use Navigation&#39;s <code>popUpTo<\/code> and <code>inclusive<\/code> flags in your action.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Custom URI Schemes vs App Links<\/h2>\n\n\n\n<p>Your app may still handle <code>yourapp:\/\/<\/code> custom URI schemes alongside HTTPS App Links. The handling code is the same: both deliver a <code>Uri<\/code> to your Activity via <code>intent.data<\/code>. The parser can handle both:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">object DeepLinkParser {\n\n    fun parse(uri: Uri): DeepLinkRoute {\n        return when (uri.scheme) {\n            &quot;https&quot; -&gt; parseHttps(uri)\n            &quot;yourapp&quot; -&gt; parseCustomScheme(uri)\n            else -&gt; DeepLinkRoute.Unknown(uri)\n        }\n    }\n\n    private fun parseHttps(uri: Uri): DeepLinkRoute {\n        \/\/ ... HTTPS parsing\n    }\n\n    private fun parseCustomScheme(uri: Uri): DeepLinkRoute {\n        \/\/ yourapp:\/\/products\/123 -&gt; ProductDetail(&quot;123&quot;)\n        val segments = uri.pathSegments\n        return when {\n            segments.size == 2 &amp;&amp; segments[0] == &quot;products&quot; -&gt;\n                DeepLinkRoute.ProductDetail(segments[1])\n            else -&gt; DeepLinkRoute.Unknown(uri)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>For a production app, App Links (HTTPS) should be your primary mechanism, with custom URI schemes as a fallback. App Links do not require the user to have seen a &quot;chooser&quot; dialog and are not spoofable by other apps, unlike custom URI schemes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Deep Link Handling<\/h2>\n\n\n\n<p>Testing deep link code is straightforward because it is just function calls with a <code>Uri<\/code> input and navigation output. Unit test your parser:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">@Test\nfun `parses product detail URL correctly`() {\n    val uri = Uri.parse(&quot;https:\/\/yourdomain.com\/products\/abc123&quot;)\n    val route = DeepLinkParser.parse(uri)\n    assertThat(route).isEqualTo(DeepLinkRoute.ProductDetail(&quot;abc123&quot;))\n}\n\n@Test\nfun `returns Unknown for unrecognized paths`() {\n    val uri = Uri.parse(&quot;https:\/\/yourdomain.com\/unknown\/path\/here&quot;)\n    val route = DeepLinkParser.parse(uri)\n    assertThat(route).isInstanceOf(DeepLinkRoute.Unknown::class.java)\n}\n<\/code><\/pre>\n\n\n\n<p>For integration testing, use ADB to fire the intent:<\/p>\n\n\n\n<pre><code class=\"language-bash\">adb shell am start \\\n  -W -a android.intent.action.VIEW \\\n  -d &quot;https:\/\/yourdomain.com\/products\/abc123&quot; \\\n  com.yourcompany.app\n<\/code><\/pre>\n\n\n\n<p>This tests the full path from intent receipt through navigation without needing a real link click.<\/p>\n\n\n\n<p>For the SDK integration to handle deferred deep links and attribution alongside this routing, see the <a href=\"https:\/\/tolinku.com\/docs\/developer\/sdks\/android\/\">Android SDK documentation<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Error Handling<\/h2>\n\n\n\n<p>Deep links arrive from outside your app and cannot be trusted to be well-formed. Always handle:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Missing required path segments<\/li>\n<li>Invalid formats (non-numeric IDs when you expect a number)<\/li>\n<li>Extra or unexpected query parameters<\/li>\n<li>Fragments in the URI (<code>#section<\/code>)<\/li>\n<li>Percent-encoded characters<\/li>\n<\/ul>\n\n\n\n<p>Kotlin&#39;s <code>runCatching<\/code> can wrap parsing operations that might throw:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">fun parse(uri: Uri): DeepLinkRoute {\n    return runCatching {\n        parseInternal(uri)\n    }.getOrElse { exception -&gt;\n        Log.e(&quot;DeepLink&quot;, &quot;Failed to parse URI: $uri&quot;, exception)\n        DeepLinkRoute.Unknown(uri)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Never let a malformed deep link crash your app. The failure should be graceful, logged for debugging, and transparent to the user.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Summary<\/h2>\n\n\n\n<p>Modern Kotlin deep link handling comes down to a few patterns working together: sealed classes model your routes as a type hierarchy, a dedicated parser converts URIs to routes with proper error handling, a ViewModel handles async resolution and navigation events, and the Navigation component manages the back stack.<\/p>\n\n\n\n<p>The result is deep link code that is testable, maintainable, and easy to extend. Adding a new route type means adding to the sealed class and updating the parser; the compiler tells you everywhere else that needs updating.<\/p>\n\n\n\n<p>For the full context on setting up App Links verification before this code has any effect, see the <a href=\"https:\/\/tolinku.com\/blog\/android-app-links-complete-guide\/\">complete Android App Links guide<\/a> and the <a href=\"https:\/\/tolinku.com\/docs\/concepts\/app-links\/\">App Links concepts documentation<\/a>. For intent filter configuration, see <a href=\"https:\/\/tolinku.com\/blog\/android-intent-filters-deep-links\/\">Android Intent Filters for Deep Links<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Modern Kotlin patterns for handling deep links in Android apps. Covers sealed classes for route modeling, Navigation component integration, coroutines for async handling, and Activity vs Fragment routing strategies.<\/p>\n","protected":false},"author":2,"featured_media":561,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Kotlin Deep Link Handling: Modern Android Patterns","rank_math_description":"Learn modern Kotlin patterns for Android deep link handling: sealed classes, Navigation component, coroutines, and Activity vs Fragment routing strategies with full code examples.","rank_math_focus_keyword":"kotlin deep link handling","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-kotlin-deep-link-handling.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-kotlin-deep-link-handling.png","footnotes":""},"categories":[10],"tags":[25,23,94,20,34,83],"class_list":["post-562","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-android","tag-android","tag-app-links","tag-coroutines","tag-deep-linking","tag-kotlin","tag-navigation"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/562","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=562"}],"version-history":[{"count":4,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/562\/revisions"}],"predecessor-version":[{"id":2815,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/562\/revisions\/2815"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/561"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=562"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=562"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=562"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}