Opening
A user taps a link in an email or social media and lands directly in your app on the exact product they wanted to see. This frictionless experience comes from understanding deep links and app links for Android & Kotlin developers. Deep links and app links solve two related but distinct problems: custom URI schemes let you define your own protocol (like myapp://product/42), while app links use verified HTTPS URLs so Android routes traffic to your app instead of a browser.
Getting this right matters in production. We've shipped e-commerce apps where users abandon carts because they land on the wrong screen, and travel apps where deep link failures silently drop users at the home page. This guide covers how to set up both approaches, integrate them into Compose navigation, and test them properly.
Deep links — Custom URIs like myapp://product/42
A deep link in its simplest form is a URI that the system can route to a specific screen in your app. Custom URIs (often called deep link schemes) let you define your own protocol prefix. When a user taps myapp://product/42, the system wakes your app and passes that URI to your navigation system.
How deep link schemes work
You declare custom schemes in your manifest. Android extracts the scheme, host, and path segments, then matches them against registered intent filters. If a match occurs, your app launches with that URI embedded in an Intent.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<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 intent filter for custom scheme -->
<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"
android:pathPrefix="/42" />
</intent-filter>
</activity>
</application>
</manifest>The android:exported="true" attribute is required when you declare intent filters on activities; it tells the system this activity can be launched by external apps or intents. The intent filter with android:scheme="myapp" intercepts URIs like myapp://product/123.
Parsing deep link URIs in Kotlin
Once Android routes the URI to your activity, you extract the path and query parameters. Modern apps use Jetpack Navigation, which handles much of this automatically if you declare the deep links in your navigation graph or on composables.
// Old-school approach: manually parse in onCreate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = intent
val data = intent.data
if (data != null) {
val scheme = data.scheme
val host = data.host
val path = data.pathSegments // List of path parts
val productId = path.lastOrNull()
if (scheme == "myapp" && host == "product") {
// Navigate to product screen with productId
navController.navigate("product/$productId")
}
}
}While manual parsing works, it quickly becomes error-prone. Jetpack Navigation handles deep link parsing declaratively, which we'll cover in the next section.
App links — Verified https links open your app directly
App links are the production-grade approach. Instead of a custom scheme that any app can claim, app links use HTTPS URLs and require domain verification. When you own myapp.com and declare an app link for https://myapp.com/product/42, Android verifies your ownership by checking for a Digital Asset Links file at https://myapp.com/.well-known/assetlinks.json. Once verified, tapping that link opens your app directly; if your app isn't installed, it falls back to the browser.
Setting up app links
First, create the Digital Asset Links JSON file and host it on your domain:
{
"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"
]
}
}
Get your app's SHA-256 fingerprint using Gradle or keytool. Then declare the app link in your manifest:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".MainActivity"
android:exported="true">
<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="myapp.com"
android:pathPrefix="/product" />
</intent-filter>
</activity>
</application>
</manifest>The android:autoVerify="true" attribute tells Android to verify the domain. Without verification, app links degrade to regular deep links and won't get the exclusive behavior.
Navigation integration — Declare deepLinks on composable destinations
Jetpack Navigation and Compose make deep link handling declarative. You define routes and their deep links on composables, and the framework routes the URI to the right destination automatically.
Defining routes with deep links in Compose
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(navController)
}
composable(
route = "product/{productId}",
deepLinks = listOf(
navDeepLink {
uriPattern = "myapp://product/{productId}"
},
navDeepLink {
uriPattern = "https://myapp.com/product/{productId}"
}
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId") ?: return@composable
ProductScreen(productId = productId, navController = navController)
}
}
}The uriPattern uses curly braces for placeholders. The framework extracts {productId} and makes it available in the arguments bundle. You can declare multiple navDeepLink blocks to support both custom schemes and app links.
Handling deep links from Intent
When your app launches from a deep link, the Intent contains the URI. For most cases, Jetpack Navigation handles this automatically, but sometimes you need to explicitly trigger navigation:
// In MainActivity or another entry point
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
// If activity is launched from a deep link, handle it
LaunchedEffect(Unit) {
navController.handleDeepLink(intent)
}
AppNavHost(navController)
}
}The handleDeepLink(intent) extension function processes the incoming URI and navigates to the matching destination.
Passing data through deep links
Beyond simple IDs, you may need to pass structured data. Query parameters work well for this:
composable(
route = "product/{productId}",
deepLinks = listOf(
navDeepLink {
uriPattern = "https://myapp.com/product/{productId}?source={source}&campaign={campaign}"
}
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId") ?: return@composable
val source = backStackEntry.arguments?.getString("source") ?: "unknown"
val campaign = backStackEntry.arguments?.getString("campaign") ?: ""
ProductScreen(
productId = productId,
source = source,
campaign = campaign
)
}A deep link like https://myapp.com/product/42?source=email&campaign=summer_sale populates all three arguments.
Test — adb shell am start with the URL
Testing deep links requires the Android Debug Bridge. Use adb shell am start to simulate tapping a link:
// Test custom scheme deep link
adb shell am start -a android.intent.action.VIEW -d "myapp://product/42"
// Test app link
adb shell am start -a android.intent.action.VIEW -d "https://myapp.com/product/42"
// With query parameters
adb shell am start -a android.intent.action.VIEW -d "https://myapp.com/product/42?source=email&campaign=sale"
The -a flag specifies the action (always VIEW for links), and -d provides the URI. After running the command, watch logcat to confirm navigation succeeded. A successful test shows your app launching and rendering the right screen.
Verifying app link verification
To confirm app links are verified, check the system logs:
adb shell pm get-app-links com.example.myappA verified app link shows status 200001 (or similar indicating "verified"). If you see 0 (default unverified), the Digital Asset Links file wasn't found or the fingerprint didn't match.
Debugging with logcat
Watch logcat during deep link testing to spot issues:
adb logcat | grep -i "deep\|navigation\|app link"Look for warnings about unmatched routes or verification failures. Jetpack Navigation logs which route it matched, helping you troubleshoot misconfigurations.
Common mistakes
- Forgetting android:exported="true" — If you declare an intent filter without this attribute, the manifest won't compile on modern Android versions. Always add it when activities receive external intents.
- Mismatched SHA-256 fingerprints — App link verification fails silently if your certificate fingerprint in assetlinks.json doesn't match your signing key. Use the correct key (debug vs. release) for each environment.
- Unreachable assetlinks.json — The file must be at
https://yourdomain.com/.well-known/assetlinks.json(not http). If it returns 404 or requires authentication, verification fails. - Incorrect path matching —
android:pathPrefix="/product"matches/product/42but not/products/42. Plan your URL structure carefully and test path edge cases. - Not handling null arguments — Deep link arguments can be missing or malformed. Always check for null and provide sensible defaults or error screens.
- Declaring the same route twice — If you declare the same deep link pattern on multiple destinations, the first match wins but behavior is unpredictable. Keep deep link patterns unique.
Key takeaways
- Custom scheme deep links (like
myapp://product/42) are quick to set up but don't verify ownership; use them for internal testing or scenarios where you control both ends. - App links leverage HTTPS and domain verification to prevent other apps from intercepting your URLs; they're the production choice for user-facing links.
- Jetpack Navigation handles deep link parsing and routing declaratively via
navDeepLinkon composable destinations; this replaces manual intent parsing. - Test deep links with
adb shell am start -dand verify app link status withpm get-app-links; logcat is your best debugging tool. - Always include query parameters for analytics (source, campaign) and ensure your app doesn't crash if deep link arguments are missing.






Comments
Loading comments…