{"id":568,"date":"2026-03-22T09:00:00","date_gmt":"2026-03-22T14:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=568"},"modified":"2026-03-07T03:48:17","modified_gmt":"2026-03-07T08:48:17","slug":"android-app-links-notifications","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/android-app-links-notifications\/","title":{"rendered":"Deep Linking from Android Notifications"},"content":{"rendered":"\n<p>Push notifications are one of the highest-value surfaces for deep linking. A notification about an order update, a new message, or a price drop is only useful if tapping it takes the user directly to the relevant screen. Yet notification deep links are implemented incorrectly more often than not: either the back stack is missing (pressing Back exits the app), the app state is ignored (the notification overwrites whatever the user was doing), or the deep link only works when the app is closed.<\/p>\n\n\n\n<p>This tutorial covers notification deep links properly, from <code>PendingIntent<\/code> construction through back stack building, foreground and background handling, and testing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Two Entry Points<\/h2>\n\n\n\n<p>When a user taps a notification, Android needs to know what to do. That instruction comes from the <code>PendingIntent<\/code> you attach to the notification when building it. There are two approaches:<\/p>\n\n\n\n<p><strong>Activity PendingIntent:<\/strong> Directly opens an Activity with an Intent. Simple, but you manage the back stack yourself.<\/p>\n\n\n\n<p><strong>TaskStackBuilder PendingIntent:<\/strong> Builds a synthetic back stack from a list of Activities or Navigation destinations. This is the correct approach for deep links because it gives users a logical Back navigation experience.<\/p>\n\n\n\n<p>The correct choice for most deep link notifications is <code>TaskStackBuilder<\/code>. Direct Activity intents leave users with no back stack, so pressing Back exits the app immediately.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Setting Up Notification Channels<\/h2>\n\n\n\n<p>Android 8.0 (API 26) requires notification channels. Before building notifications, register channels during app initialization:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">object NotificationChannels {\n    const val ORDERS = &quot;orders&quot;\n    const val MESSAGES = &quot;messages&quot;\n    const val PROMOTIONS = &quot;promotions&quot;\n\n    fun createChannels(context: Context) {\n        if (Build.VERSION.SDK_INT &lt; Build.VERSION_CODES.O) return\n\n        val notificationManager =\n            context.getSystemService(NotificationManager::class.java)\n\n        val channels = listOf(\n            NotificationChannel(\n                ORDERS,\n                &quot;Order Updates&quot;,\n                NotificationManager.IMPORTANCE_HIGH\n            ).apply {\n                description = &quot;Updates about your orders&quot;\n            },\n            NotificationChannel(\n                MESSAGES,\n                &quot;Messages&quot;,\n                NotificationManager.IMPORTANCE_DEFAULT\n            ).apply {\n                description = &quot;New messages from other users&quot;\n            },\n            NotificationChannel(\n                PROMOTIONS,\n                &quot;Promotions&quot;,\n                NotificationManager.IMPORTANCE_LOW\n            ).apply {\n                description = &quot;Deals and promotional offers&quot;\n            }\n        )\n\n        channels.forEach { notificationManager.createNotificationChannel(it) }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Call <code>NotificationChannels.createChannels(context)<\/code> in your <code>Application.onCreate()<\/code>. Creating channels is idempotent, so calling it on every launch is safe.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Building a Deep Link Notification with TaskStackBuilder<\/h2>\n\n\n\n<p>Here is a complete example for an order update notification that deep links to an order detail screen:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">fun buildOrderNotification(\n    context: Context,\n    orderId: String,\n    statusText: String\n): Notification {\n\n    \/\/ The destination intent: what screen to show when notification is tapped\n    val destinationIntent = Intent(\n        Intent.ACTION_VIEW,\n        &quot;https:\/\/yourdomain.com\/orders\/$orderId&quot;.toUri(),\n        context,\n        MainActivity::class.java\n    )\n\n    \/\/ TaskStackBuilder constructs the back stack\n    val stackBuilder = TaskStackBuilder.create(context).apply {\n        \/\/ Add the parent chain first\n        addParentStack(MainActivity::class.java)\n        \/\/ Add the destination on top\n        addNextIntent(destinationIntent)\n    }\n\n    val pendingIntent = stackBuilder.getPendingIntent(\n        orderId.hashCode(), \/\/ Unique request code per notification\n        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE\n    )\n\n    return NotificationCompat.Builder(context, NotificationChannels.ORDERS)\n        .setSmallIcon(R.drawable.ic_notification)\n        .setContentTitle(&quot;Order Update&quot;)\n        .setContentText(statusText)\n        .setAutoCancel(true)\n        .setContentIntent(pendingIntent)\n        .setPriority(NotificationCompat.PRIORITY_HIGH)\n        .build()\n}\n<\/code><\/pre>\n\n\n\n<p><code>PendingIntent.FLAG_IMMUTABLE<\/code> is required for API 31 and above. <code>FLAG_UPDATE_CURRENT<\/code> ensures that if a notification with the same request code already exists, the intent data is updated rather than a duplicate being created.<\/p>\n\n\n\n<p>The request code (<code>orderId.hashCode()<\/code>) should be unique per notification. If you use the same request code for two different notifications, the second will overwrite the first&#39;s <code>PendingIntent<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Using NavDeepLinkBuilder with Navigation Component<\/h2>\n\n\n\n<p>If your app uses the Jetpack Navigation component, <code>NavDeepLinkBuilder<\/code> is cleaner than constructing intents manually. It understands your nav graph and builds the back stack from the graph structure automatically:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">fun buildOrderNotification(\n    context: Context,\n    orderId: String,\n    statusText: String\n): Notification {\n\n    val pendingIntent = NavDeepLinkBuilder(context)\n        .setGraph(R.navigation.nav_graph)\n        .setDestination(R.id.orderDetailFragment)\n        .setArguments(bundleOf(&quot;orderId&quot; to orderId))\n        .createPendingIntent()\n\n    return NotificationCompat.Builder(context, NotificationChannels.ORDERS)\n        .setSmallIcon(R.drawable.ic_notification)\n        .setContentTitle(&quot;Order Update&quot;)\n        .setContentText(statusText)\n        .setAutoCancel(true)\n        .setContentIntent(pendingIntent)\n        .setPriority(NotificationCompat.PRIORITY_HIGH)\n        .build()\n}\n<\/code><\/pre>\n\n\n\n<p><code>NavDeepLinkBuilder<\/code> reads the nav graph to determine the parent destinations and constructs the back stack accordingly. The user arrives at <code>orderDetailFragment<\/code> with <code>homeFragment<\/code> (the graph&#39;s start destination) in the back stack. Pressing Back navigates to Home, not out of the app.<\/p>\n\n\n\n<p>If the destination is inside a nested graph, <code>NavDeepLinkBuilder<\/code> includes the nested graph&#39;s start destination in the back stack as well.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Foreground vs Background App States<\/h2>\n\n\n\n<p>The behavior when a notification is tapped depends on whether your app is running in the foreground, in the background, or not running at all.<\/p>\n\n\n\n<p><strong>App not running:<\/strong> Android creates a fresh task using your <code>PendingIntent<\/code>. The back stack from <code>TaskStackBuilder<\/code> or <code>NavDeepLinkBuilder<\/code> is created as-is. This works correctly with both approaches.<\/p>\n\n\n\n<p><strong>App in background:<\/strong> The system brings your existing task to the foreground. If you used <code>TaskStackBuilder<\/code> with <code>FLAG_UPDATE_CURRENT<\/code>, the destination intent&#39;s data is updated, and the Activity handles it. This depends on how your <code>MainActivity<\/code> is configured.<\/p>\n\n\n\n<p><strong>App in foreground:<\/strong> The user is actively using the app. Tapping the notification should not discard the current state. This is where things need attention.<\/p>\n\n\n\n<p>For a <code>singleTop<\/code> or <code>singleTask<\/code> launch mode Activity, the system calls <code>onNewIntent<\/code> instead of creating a new instance. Handle this in your Activity:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class MainActivity : AppCompatActivity() {\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        \/\/ ...\n        handleIntent(intent)\n    }\n\n    override fun onNewIntent(intent: Intent) {\n        super.onNewIntent(intent)\n        handleIntent(intent)\n    }\n\n    private fun handleIntent(intent: Intent) {\n        intent.data?.let { uri -&gt;\n            navController.handleDeepLink(intent)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>With <code>NavDeepLinkBuilder<\/code>, if the app is in the foreground, Navigation handles routing to the destination without destroying the current state. The destination is added to the current back stack rather than replacing it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Notification Deep Links with Custom Data<\/h2>\n\n\n\n<p>Sometimes you want to pass extra data with the notification beyond what fits in the URL. For example, a notification might include a thumbnail URL for display that does not belong in the deep link URI.<\/p>\n\n\n\n<p>Pass extra data through the Intent extras, then read them in your Activity or Fragment:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">val destinationIntent = Intent(\n    Intent.ACTION_VIEW,\n    &quot;https:\/\/yourdomain.com\/orders\/$orderId&quot;.toUri(),\n    context,\n    MainActivity::class.java\n).apply {\n    putExtra(&quot;notification_id&quot;, notificationId)\n    putExtra(&quot;thumbnail_url&quot;, thumbnailUrl)\n}\n<\/code><\/pre>\n\n\n\n<p>In the receiving Fragment:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun onViewCreated(view: View, savedInstanceState: Bundle?) {\n    super.onViewCreated(view, savedInstanceState)\n\n    val orderId = args.orderId \/\/ From Navigation safe args\n    val thumbnailUrl = activity?.intent?.getStringExtra(&quot;thumbnail_url&quot;)\n\n    viewModel.loadOrder(orderId, thumbnailUrl)\n}\n<\/code><\/pre>\n\n\n\n<p>Be careful with extras when using <code>NavDeepLinkBuilder<\/code>: extras passed to the builder are added to the <code>PendingIntent<\/code> extras, but Navigation uses the Bundle passed to <code>setArguments()<\/code> for navigation arguments. Keep navigation arguments in the Bundle and supplementary data in extras.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Notification Deep Links in BroadcastReceiver<\/h2>\n\n\n\n<p>Some notification actions (like dismissing, replying, or button taps) route through a <code>BroadcastReceiver<\/code> rather than directly to an Activity. If you need to navigate after receiving a broadcast, you have to start the Activity from the receiver:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class NotificationActionReceiver : BroadcastReceiver() {\n\n    override fun onReceive(context: Context, intent: Intent) {\n        val orderId = intent.getStringExtra(&quot;order_id&quot;) ?: return\n        val action = intent.getStringExtra(&quot;action&quot;) ?: return\n\n        when (action) {\n            &quot;MARK_RECEIVED&quot; -&gt; {\n                \/\/ Handle the action\n                OrderRepository.markReceived(orderId)\n\n                \/\/ Navigate to confirmation\n                val launchIntent = NavDeepLinkBuilder(context)\n                    .setGraph(R.navigation.nav_graph)\n                    .setDestination(R.id.orderConfirmationFragment)\n                    .setArguments(bundleOf(&quot;orderId&quot; to orderId))\n                    .createTaskStackBuilder()\n                    .getPendingIntent(\n                        orderId.hashCode(),\n                        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE\n                    )\n\n                launchIntent?.send()\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Note that <code>BroadcastReceiver.onReceive<\/code> is called on the main thread with a limited execution window. Do not perform long-running operations here. If you need async work (like a network call) before navigating, use <code>goAsync()<\/code> to extend the receiver&#39;s lifetime or dispatch the work to a <code>WorkManager<\/code> task.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Notification Permission on Android 13 and Later<\/h2>\n\n\n\n<p>Android 13 (API 33) added a runtime permission for notifications: <code>POST_NOTIFICATIONS<\/code>. Apps targeting API 33 or higher must request this permission before posting notifications.<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">\/\/ Check and request notification permission\nif (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.TIRAMISU) {\n    when {\n        ContextCompat.checkSelfPermission(\n            this,\n            Manifest.permission.POST_NOTIFICATIONS\n        ) == PackageManager.PERMISSION_GRANTED -&gt; {\n            \/\/ Permission granted, can post notifications\n        }\n        shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -&gt; {\n            \/\/ Show rationale then request\n            showNotificationPermissionRationale()\n        }\n        else -&gt; {\n            requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Without this permission on API 33+ devices, your notifications will be silently dropped. The deep link code is correct, but the notification never appears.<\/p>\n\n\n\n<p>Declare the permission in your manifest:<\/p>\n\n\n\n<pre><code class=\"language-xml\">&lt;uses-permission android:name=&quot;android.permission.POST_NOTIFICATIONS&quot; \/&gt;\n<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1080\" height=\"608\" src=\"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/android-app-links-notifications-inline-2.jpg\" alt=\"Close-up of a smartphone screen displaying time and time\" class=\"wp-image-481\" srcset=\"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/android-app-links-notifications-inline-2.jpg 1080w, https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/android-app-links-notifications-inline-2-300x169.jpg 300w, https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/android-app-links-notifications-inline-2-1024x576.jpg 1024w, https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/android-app-links-notifications-inline-2-768x432.jpg 768w\" sizes=\"auto, (max-width: 1080px) 100vw, 1080px\" \/><figcaption class=\"wp-element-caption\">Photo by <a href=\"https:\/\/unsplash.com\/@amanz?utm_source=tolinku&#038;utm_medium=referral\" rel=\"nofollow noopener\" target=\"_blank\">Amanz<\/a> on <a href=\"https:\/\/unsplash.com\/?utm_source=tolinku&#038;utm_medium=referral\" rel=\"nofollow noopener\" target=\"_blank\">Unsplash<\/a><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Notification Deep Links<\/h2>\n\n\n\n<p>Testing notification deep links requires verifying that:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The notification appears with the correct content<\/li>\n<li>Tapping the notification navigates to the correct screen<\/li>\n<li>The back stack is correct after navigation<\/li>\n<li>The behavior is correct in all app states (cold start, background, foreground)<\/li>\n<\/ol>\n\n\n\n<p>For automated testing, use <code>NotificationListenerService<\/code> or test the <code>PendingIntent<\/code> directly:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">@Test\nfun notificationDeepLinkNavigatesToOrderDetail() {\n    val orderId = &quot;test-order-123&quot;\n    val notification = buildOrderNotification(\n        ApplicationProvider.getApplicationContext(),\n        orderId,\n        &quot;Your order has shipped&quot;\n    )\n\n    \/\/ Extract and fire the content intent\n    val contentIntent = notification.contentIntent\n\n    val scenario = ActivityScenario.launch&lt;MainActivity&gt;(\n        Intent(Intent.ACTION_MAIN)\n            .addCategory(Intent.CATEGORY_LAUNCHER)\n    )\n\n    scenario.onActivity { activity -&gt;\n        contentIntent.send()\n    }\n\n    \/\/ Verify navigation state\n    scenario.onActivity { activity -&gt;\n        val navController = activity.findNavController(R.id.nav_host_fragment)\n        assertThat(navController.currentDestination?.id)\n            .isEqualTo(R.id.orderDetailFragment)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>For manual testing during development, use ADB to simulate a notification tap:<\/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\/orders\/test-order-123&quot; \\\n  com.yourcompany.app\n<\/code><\/pre>\n\n\n\n<p>This bypasses the notification system and tests the deep link routing directly. It does not test the notification construction, but it verifies that the destination Intent routes correctly.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common Mistakes<\/h2>\n\n\n\n<p><strong>Using FLAG_MUTABLE when FLAG_IMMUTABLE is required.<\/strong> On Android 12 and above, all <code>PendingIntent<\/code> objects must be explicitly marked as mutable or immutable. Use <code>FLAG_IMMUTABLE<\/code> unless you have a specific reason to allow the Intent to be mutated.<\/p>\n\n\n\n<p><strong>Same request code for all notifications.<\/strong> If you build multiple notifications with the same request code, each new <code>PendingIntent<\/code> overwrites the previous one. Use unique request codes, typically derived from the notification&#39;s data (an order ID, user ID, etc.).<\/p>\n\n\n\n<p><strong>No back stack.<\/strong> Constructing a <code>PendingIntent<\/code> directly from an Activity Intent without <code>TaskStackBuilder<\/code> leaves the user with an empty back stack. Always use <code>TaskStackBuilder<\/code> or <code>NavDeepLinkBuilder<\/code>.<\/p>\n\n\n\n<p><strong>Not handling <code>onNewIntent<\/code>.<\/strong> If your Activity has <code>singleTop<\/code> or <code>singleTask<\/code> launch mode and you do not handle <code>onNewIntent<\/code>, notification taps when the app is already running go nowhere.<\/p>\n\n\n\n<p><strong>Missing notification permission on API 33+.<\/strong> Notifications are silently dropped without <code>POST_NOTIFICATIONS<\/code> permission on Android 13 and later.<\/p>\n\n\n\n<p>For the full App Links setup that makes HTTPS deep link URLs work with your app, see the <a href=\"https:\/\/tolinku.com\/docs\/user-guide\/configuring-android\/\">configuring Android guide<\/a>, and for an overview of all deep linking concepts, see our <a href=\"https:\/\/tolinku.com\/blog\/complete-guide-deep-linking-2026\/\">complete guide to deep linking<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Summary<\/h2>\n\n\n\n<p>Notification deep links require correct <code>PendingIntent<\/code> construction, proper back stack building, and handling for all app lifecycle states.<\/p>\n\n\n\n<p>Use <code>NavDeepLinkBuilder<\/code> if your app uses the Jetpack Navigation component; it handles back stack construction automatically from your nav graph. Use <code>TaskStackBuilder<\/code> for manual Activity stack construction in simpler apps.<\/p>\n\n\n\n<p>The key rules: always build a back stack (never a bare Activity Intent), always handle <code>onNewIntent<\/code> for foreground and background cases, use unique request codes per notification, and request <code>POST_NOTIFICATIONS<\/code> permission for Android 13+ users.<\/p>\n\n\n\n<p>Getting this right means a user tapping your notification always lands exactly where they should, with a logical navigation history, in any app state.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A practical tutorial on implementing notification deep links for Android. Covers PendingIntent construction, TaskStackBuilder for back stack management, foreground and background app states, notification channels, and testing strategies.<\/p>\n","protected":false},"author":2,"featured_media":567,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Deep Linking from Android Notifications: Full Tutorial","rank_math_description":"Learn how to build Android notification deep links with PendingIntent, TaskStackBuilder, and Jetpack Navigation. Covers foreground and background states, channels, and testing.","rank_math_focus_keyword":"android notification deep 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-app-links-notifications.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-app-links-notifications.png","footnotes":""},"categories":[10],"tags":[25,23,20,34,96,84],"class_list":["post-568","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-android","tag-android","tag-app-links","tag-deep-linking","tag-kotlin","tag-pending-intent","tag-push-notifications"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/568","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=568"}],"version-history":[{"count":2,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/568\/revisions"}],"predecessor-version":[{"id":2486,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/568\/revisions\/2486"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/567"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=568"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=568"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=568"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}