A user clicks a link on Twitter. Instead of opening a web page, your app opens directly to the product detail screen. Another user scans a QR code and lands on the signup page inside your app.

This is what deep links do. They let URLs open specific screens in your app.

In this tutorial, you will learn the difference between deep links and App Links, how to set up both, and how to integrate them with Compose Navigation.

Prerequisites: You should understand Compose Navigation. Read Compose Tutorial #8: Navigation if needed.


Android supports three types of links:

TypeVerificationDisambiguation DialogUse Case
Deep linkNoneYes — user picks which appCustom schemes (myapp://)
Web linkNoneYeshttp:// and https://
App LinkVerifiedNo — opens your app directlyVerified https:// links

Deep links use custom schemes like myapp://product/123. Any app can register the same scheme, so Android shows a disambiguation dialog.

App Links use verified https:// URLs. Android checks a file on your server to confirm that your app owns the domain. No dialog — your app opens directly.

Recommendation: Use App Links for production. Deep links are fine for development and testing.


Step 1: Intent Filter

Add an intent filter to your activity in AndroidManifest.xml:

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <!-- Deep link: myapp://product/{id} -->
    <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="product" />
    </intent-filter>

    <!-- Web link: https://example.com/product/{id} -->
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="example.com"
            android:pathPrefix="/product" />
    </intent-filter>
</activity>

Step 2: Handle the Intent

Read the deep link data from the activity’s intent:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MyApp(intent = intent)
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        // Handle deep link when app is already open
    }
}

Compose Navigation has built-in support for deep links. You define them in your navigation graph.

Type-Safe Routes

Since Navigation 2.8+, you can use @Serializable route classes:

@Serializable
data class ProductRoute(val productId: String)

@Serializable
object HomeRoute

@Serializable
object ProfileRoute
@Composable
fun AppNavigation(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = HomeRoute
    ) {
        composable<HomeRoute>(
            deepLinks = listOf(
                navDeepLink<HomeRoute>(
                    basePath = "https://example.com"
                )
            )
        ) {
            HomeScreen(
                onProductClick = { productId ->
                    navController.navigate(ProductRoute(productId))
                }
            )
        }

        composable<ProductRoute>(
            deepLinks = listOf(
                navDeepLink<ProductRoute>(
                    basePath = "https://example.com/product"
                ),
                navDeepLink<ProductRoute>(
                    basePath = "myapp://product"
                )
            )
        ) { backStackEntry ->
            val route = backStackEntry.toRoute<ProductRoute>()
            ProductDetailScreen(productId = route.productId)
        }

        composable<ProfileRoute>(
            deepLinks = listOf(
                navDeepLink<ProfileRoute>(
                    basePath = "https://example.com/profile"
                )
            )
        ) {
            ProfileScreen()
        }
    }
}

Now these URLs work automatically:

  • myapp://product/abc123 opens the product detail screen
  • https://example.com/product/abc123 opens the product detail screen
  • https://example.com/profile opens the profile screen

Pass the intent to the NavController:

@Composable
fun MyApp(intent: Intent?) {
    val navController = rememberNavController()

    LaunchedEffect(intent) {
        intent?.data?.let { uri ->
            navController.handleDeepLink(intent)
        }
    }

    AppNavigation(navController = navController)
}

App Links are the production-ready solution. When a user clicks https://example.com/product/123, your app opens without a disambiguation dialog.

Step 1: Add autoVerify

Set android:autoVerify="true" on your intent filter (already shown above).

Create a JSON file at https://example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.myapp",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
      ]
    }
  }
]

Getting Your SHA-256 Fingerprint

# Debug keystore
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android

# Release keystore
keytool -list -v -keystore your-release.keystore -alias your-alias

# From Play App Signing (recommended)
# Go to Play Console → Your App → Setup → App signing
# Copy the SHA-256 certificate fingerprint

Step 3: Host the File

The file must be:

  • Served over HTTPS
  • At exactly /.well-known/assetlinks.json
  • Content type: application/json
  • Accessible without redirects
  • Returning HTTP 200

Example Nginx configuration:

location /.well-known/assetlinks.json {
    root /var/www/example.com;
    default_type application/json;
}

Step 4: Verify

Use the Statement List Generator tool to test:

# Test with adb
adb shell am start -a android.intent.action.VIEW \
    -d "https://example.com/product/123" com.example.myapp

# Check verification status
adb shell pm get-app-links com.example.myapp

When a push notification should open a specific screen, include the deep link in the notification:

fun showDeepLinkNotification(context: Context, productId: String) {
    val deepLinkUri = "https://example.com/product/$productId".toUri()

    val intent = Intent(Intent.ACTION_VIEW, deepLinkUri).apply {
        setPackage(context.packageName)
        flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
    }

    val pendingIntent = PendingIntent.getActivity(
        context, 0, intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    val notification = NotificationCompat.Builder(context, "products")
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("New Product Available")
        .setContentText("Check out this item!")
        .setContentIntent(pendingIntent)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(context).notify(100, notification)
}

If you use FCM for push notifications, include the deep link URL in the notification payload:

class MyFirebaseMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val deepLink = remoteMessage.data["deep_link"]
        val title = remoteMessage.data["title"] ?: "Notification"
        val body = remoteMessage.data["body"] ?: ""

        showNotification(title, body, deepLink)
    }

    private fun showNotification(title: String, body: String, deepLink: String?) {
        val intent = if (deepLink != null) {
            Intent(Intent.ACTION_VIEW, deepLink.toUri()).apply {
                setPackage(packageName)
            }
        } else {
            Intent(this, MainActivity::class.java)
        }

        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val notification = NotificationCompat.Builder(this, "messages")
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(title)
            .setContentText(body)
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
            .build()

        NotificationManagerCompat.from(this).notify(
            System.currentTimeMillis().toInt(),
            notification
        )
    }
}

Post-Firebase Dynamic Links

Firebase Dynamic Links shut down in August 2025. If you need deferred deep linking (where the link works even when the app is not installed), you have two options:

Use standard App Links. If the app is not installed, the link opens in the browser. On your website, show a “Get the app” banner with a link to the Play Store.

Option 2: Third-Party Services

Services like Branch.io and Adjust provide deferred deep linking:

  • User clicks a link
  • If the app is not installed, they go to the Play Store
  • After installing, the app opens to the deep-linked screen

These services cost money but solve the deferred deep linking problem that Firebase Dynamic Links used to solve for free.

For most apps, Option 1 is enough. Users are used to installing an app and then clicking the link again.


With adb

# Test custom scheme
adb shell am start -a android.intent.action.VIEW \
    -d "myapp://product/abc123"

# Test HTTPS link
adb shell am start -a android.intent.action.VIEW \
    -d "https://example.com/product/abc123"

# Test with specific package
adb shell am start -a android.intent.action.VIEW \
    -d "https://example.com/product/abc123" \
    com.example.myapp

With a Test Composable

@Composable
fun DeepLinkTestScreen(navController: NavController) {
    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text("Deep Link Test", style = MaterialTheme.typography.headlineMedium)

        val testLinks = listOf(
            "myapp://product/123" to "Product 123",
            "myapp://product/456" to "Product 456",
            "https://example.com/profile" to "Profile"
        )

        testLinks.forEach { (link, label) ->
            val context = LocalContext.current
            OutlinedButton(
                onClick = {
                    val intent = Intent(Intent.ACTION_VIEW, link.toUri())
                    context.startActivity(intent)
                },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(label)
            }
        }
    }
}
# Check if App Links are verified
adb shell pm get-app-links --user cur com.example.myapp

# Reset verification (for testing)
adb shell pm set-app-links --package com.example.myapp 0 all

# Trigger re-verification
adb shell pm verify-app-links --re-verify com.example.myapp

Track which deep links bring users to your app:

fun trackDeepLink(context: Context, intent: Intent?) {
    val uri = intent?.data ?: return

    val source = uri.getQueryParameter("utm_source") ?: "unknown"
    val medium = uri.getQueryParameter("utm_medium") ?: "unknown"
    val campaign = uri.getQueryParameter("utm_campaign") ?: "unknown"

    // Log to Firebase Analytics
    FirebaseAnalytics.getInstance(context).logEvent("deep_link_opened") {
        param("link_url", uri.toString())
        param("utm_source", source)
        param("utm_medium", medium)
        param("utm_campaign", campaign)
    }
}

Use UTM parameters in your deep links:

https://example.com/product/123?utm_source=twitter&utm_medium=social&utm_campaign=summer_sale

Privacy Sandbox Awareness

For deep link attribution, be aware that Android’s Privacy Sandbox is changing how tracking works. The Topics API and Attribution Reporting API replace legacy tracking identifiers. If your app relies on deep link attribution for advertising, review the Privacy Sandbox documentation for the latest requirements.


Common Mistakes

1. Forgetting CATEGORY_BROWSABLE

Without android.intent.category.BROWSABLE, links from browsers will not trigger your intent filter. Always include both DEFAULT and BROWSABLE categories.

2. Not Handling onNewIntent

If your app is already open and a deep link arrives, onCreate is not called again. Override onNewIntent in your activity and update the NavController.

3. Wrong assetlinks.json Location

The file must be at /.well-known/assetlinks.json — not /assetlinks.json or any other path. Also check that your server does not redirect HTTPS requests (redirects cause verification to fail).

4. Using Debug Fingerprint in Production

The SHA-256 fingerprint for your debug keystore is different from your release keystore. If you use Play App Signing, use the fingerprint from the Play Console, not your local keystore.

5. Not Testing on Clean Install

Deep link handling can behave differently depending on whether the app was already installed, just installed, or is being opened for the first time. Test all three scenarios.


What’s Next?

In this tutorial, you learned:

  • The difference between deep links, web links, and App Links
  • Setting up intent filters in the manifest
  • Integrating deep links with Compose Navigation and type-safe routes
  • Verifying App Links with Digital Asset Links
  • Sending deep links from push notifications
  • Post-Firebase Dynamic Links alternatives for deferred deep linking
  • Testing and verifying deep links with adb

Next up: Android Tutorial #15: Widgets with Glance — where you will learn how to build home screen widgets using Compose-like syntax.



This is part 14 of the Android Development Tutorial series.