Android App Links Guide
Complete step-by-step guide to implement, configure, and test Android App Links in your mobile application
Quick Start: Copy-Paste Setup
Get App Links working in minutes. Replace YOUR-SUBDOMAIN with your DeepTap subdomain.
1. Intent Filter (AndroidManifest.xml)
Add this inside your main Activity in AndroidManifest.xml:
<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="YOUR-SUBDOMAIN.deeptap.io" />
</intent-filter>2. Activity Handler (Kotlin)
Add this to your MainActivity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
if (Intent.ACTION_VIEW == intent.action) {
val data: Uri? = intent.data
if (data != null) {
val path = data.path // e.g., "/product/123"
// Route to the appropriate screen based on path
}
}
}Note: Make sure you've configured your Package Name and SHA-256 fingerprints in the DeepTap dashboard before testing.
Table of Contents
1. Overview & Prerequisites
What are Android App Links?
Android App Links are HTTP URLs that bring users directly to specific content in your Android app. They provide a seamless user experience by opening your app instead of a web browser when users click on links.
Types of Deep Links
Deep Links
Basic URI schemes that can open your app
myapp://product/123Web Links
HTTP/HTTPS URLs that show intent chooser
https://example.com/product/123Android App Links
Verified HTTPS URLs that open directly
https://verified.com/product/123- • Direct app opening from web links
- • Better user engagement and retention
- • Seamless content sharing between web and app
- • Enhanced SEO and discoverability
- • No app chooser dialog for verified domains
Prerequisites
- • Android Studio
- • Target SDK 23+
- • Kotlin or Java knowledge
- • ADB access for testing
- • Managed domain and hosting for verification files
- • Automatic HTTPS with valid SSL certificates
- • Auto-generated and hosted
/.well-known/assetlinks.json - • Seamless Android domain verification support
2. Configure Intent Filters
Add Intent Filters to AndroidManifest.xml
Configure your app to handle specific URL patterns by adding intent filters to your target activity.
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- Standard activity intent filter -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- App Links intent filter -->
<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="yourdomain.com" />
</intent-filter>
<!-- Multiple path patterns example -->
<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="yourdomain.com"
android:pathPrefix="/product" />
</intent-filter>
</activity>Intent Filter Parameters Explained
Required Elements
android:autoVerify="true"- Enables automatic verificationACTION_VIEW- Standard action for viewing contentBROWSABLE- Allows access from browserDEFAULT- Default category for implicit intents
Data Attributes
android:scheme- URL scheme (https/http)android:host- Domain nameandroid:pathPrefix- URL path patternandroid:path- Exact path matchandroid:pathPattern- Path with wildcards (* and .)android:port- Specific port number
3. Setup Digital Asset Links
Create assetlinks.json File
Digital Asset Links verify that your app is authorized to open links for your domain. Create this file and host it on your domain.
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"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 SHA256 Fingerprint
For Debug Builds
keytool -list -v \ -keystore ~/.android/debug.keystore \ -alias androiddebugkey \ -storepass android \ -keypass android
For Release Builds
keytool -list -v \ -keystore your-release-key.keystore \ -alias your-key-alias
From Google Play Console
Go to Play Console → Your App → Setup → App Integrity → App Signing → SHA-256 certificate fingerprint
Host the File
- • File must be accessible at:
https://yourdomain.com/.well-known/assetlinks.json - • Must be served with
Content-Type: application/json - • Must be accessible via HTTPS
- • No redirects allowed
4. Handle Links in Activity
Basic Link Handling (Kotlin)
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
handleAppLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleAppLink(intent)
}
private fun handleAppLink(intent: Intent) {
val appLinkAction = intent.action
val appLinkData: Uri? = intent.data
if (Intent.ACTION_VIEW == appLinkAction && appLinkData != null) {
handleIncomingLink(appLinkData)
}
}
private fun handleIncomingLink(uri: Uri) {
// Extract path and parameters
val path = uri.path
val queryParams = uri.queryParameterNames
when {
path?.startsWith("/product") == true -> {
val productId = uri.getQueryParameter("id")
openProduct(productId)
}
path?.startsWith("/dashboard") == true -> {
openDashboard()
}
path?.startsWith("/profile") == true -> {
val userId = uri.getQueryParameter("user")
openProfile(userId)
}
else -> {
// Handle unknown path - maybe show home screen
showHome()
}
}
}
private fun openProduct(productId: String?) {
productId?.let {
// Navigate to product screen
val intent = Intent(this, ProductActivity::class.java)
intent.putExtra("product_id", it)
startActivity(intent)
}
}
private fun openDashboard() {
// Navigate to dashboard
val intent = Intent(this, DashboardActivity::class.java)
startActivity(intent)
}
private fun openProfile(userId: String?) {
userId?.let {
// Navigate to profile screen
val intent = Intent(this, ProfileActivity::class.java)
intent.putExtra("user_id", it)
startActivity(intent)
}
}
private fun showHome() {
// Show default home content
// Already in MainActivity, just ensure we're showing home fragment
}
}Advanced Routing with Navigation Component
class MainActivity : AppCompatActivity() {
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHostFragment.navController
handleAppLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleAppLink(intent)
}
private fun handleAppLink(intent: Intent) {
val uri = intent.data
uri?.let {
when (it.path) {
"/product" -> {
val productId = it.getQueryParameter("id")
val bundle = Bundle().apply {
putString("productId", productId)
}
navController.navigate(R.id.productFragment, bundle)
}
"/dashboard" -> {
navController.navigate(R.id.dashboardFragment)
}
"/profile" -> {
val userId = it.getQueryParameter("user")
val bundle = Bundle().apply {
putString("userId", userId)
}
navController.navigate(R.id.profileFragment, bundle)
}
}
}
}
}🔒 Security Considerations
Input Validation
Always validate and sanitize incoming URL data to prevent security vulnerabilities.
private fun handleIncomingLink(uri: Uri) {
val path = uri.path
val productId = uri.getQueryParameter("id")
// ❌ Bad: Direct usage without validation
// openProduct(productId)
// ✅ Good: Validate input first
if (path != null && isValidPath(path)) {
productId?.let { id ->
if (isValidProductId(id)) {
openProduct(id)
} else {
Log.w("AppLinks", "Invalid product ID: $id")
showHome()
}
}
} else {
Log.w("AppLinks", "Invalid or null path: $path")
showHome()
}
}
private fun isValidProductId(id: String): Boolean {
return id.matches(Regex("^[a-zA-Z0-9_-]{1,50}$"))
}
private fun isValidPath(path: String): Boolean {
val allowedPaths = listOf("/product", "/dashboard", "/profile")
return allowedPaths.any { path.startsWith(it) }
}Authentication & Authorization
- • Always check user authentication before opening sensitive content
- • Validate user permissions for accessed resources
- • Don't expose sensitive data through URL parameters
- • Use secure tokens for authenticated deep links
- • Implement rate limiting for link processing
Best Security Practices
HTTPS Only
Always use HTTPS for App Links to prevent man-in-the-middle attacks and ensure data integrity.
Certificate Pinning
Consider implementing certificate pinning for critical domains to prevent certificate-based attacks.
Minimal Permissions
Only request necessary permissions and avoid exposing sensitive app functionality through deep links.
Error Handling
Implement proper error handling and logging without exposing sensitive information to attackers.
6. Testing & Debugging
ADB Testing Commands
Test App Link Opening
adb shell am start \
-W -a android.intent.action.VIEW \
-d "https://yourdomain.com/product?id=123" \
com.yourcompany.yourappCheck Domain Verification Status
adb shell pm get-app-links com.yourcompany.yourapp
Reset Domain Verification
adb shell pm set-app-links --package com.yourcompany.yourapp 0 yourdomain.com
Verification Status Codes
- verified - Domain verification successful
- none - No verification attempted
- ask - User needs to approve
- denied - User denied permission
- always_ask - Always show chooser
- legacy_failure - Verification failed
Debugging Tips
- • Use Android Studio's logcat to monitor intent data
- • Test with both debug and release builds
- • Verify assetlinks.json is accessible in browser
- • Check network connectivity during verification
- • Use Google's Digital Asset Links API to validate your file
7. Common Issues & Solutions
❌ App doesn't open when clicking links
- • Check if assetlinks.json is properly hosted and accessible
- • Verify SHA256 fingerprint matches your app's signing certificate
- • Ensure android:autoVerify="true" is set in intent filters
- • Test domain verification status with ADB commands
⚠️ Verification fails
- • Verify assetlinks.json returns Content-Type: application/json
- • Check for any redirects - they're not allowed
- • Ensure file is served over HTTPS
- • Validate JSON syntax with online tools
ℹ️ Browser chooser still appears
- • Domain verification may be in progress (can take up to 20 seconds)
- • Clear app defaults in Settings → Apps → Your App → Open by default
- • Reset verification status and wait for re-verification
- • Check if other apps also handle the same domain
✅ Best Practices
- • Always test on physical devices, not just emulators
- • Include both debug and release certificate fingerprints during development
- • Set launchMode="singleTop" to handle new intents properly
- • Implement proper fallback handling for unrecognized URLs
- • Use HTTPS for all domain URLs
8. Deferred Deep Links
What are Deferred Deep Links?
Deferred deep links solve a common problem: when a user clicks a link but doesn't have your app installed yet. DeepTap stores the link data server-side, and after the user installs your app, you can retrieve that original link to navigate them to the correct content.
How It Works
- User clicks an App Link but doesn't have your app
- DeepTap stores the link data with a fingerprint (IP + User Agent)
- User is redirected to Play Store to download your app
- On first launch, your app calls DeepTap API to check for stored links
- If a match is found, navigate the user to the original content
API Endpoint
Call this endpoint on first app launch to check for deferred links:
GET https://deeptap.io/api/deferred-link?subdomain=YOUR-SUBDOMAIN.deeptap.io
Response (success):
{
"success": true,
"data": {
"path": "/product/123",
"queryParams": { "ref": "campaign" },
"referrer": "https://twitter.com",
"createdAt": "2024-01-15T10:30:00Z"
}
}
Response (no match):
{
"success": false,
"error": "No deferred link found"
}Kotlin Implementation
Implementation using coroutines and OkHttp/HttpURLConnection:
// DeferredLinkService.kt
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
data class DeferredLinkData(
val path: String,
val queryParams: Map<String, String>?,
val referrer: String?,
val createdAt: String
)
class DeferredLinkService(private val context: Context) {
private val subdomain = "YOUR-SUBDOMAIN.deeptap.io"
private val prefs = context.getSharedPreferences("deeptap_prefs", Context.MODE_PRIVATE)
suspend fun checkDeferredDeepLink(): DeferredLinkData? {
// Only check on first launch
if (prefs.getBoolean("deferred_link_checked", false)) {
return null
}
// Mark as checked
prefs.edit().putBoolean("deferred_link_checked", true).apply()
return withContext(Dispatchers.IO) {
try {
val url = URL("https://deeptap.io/api/deferred-link?subdomain=$subdomain")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
if (connection.responseCode == 200) {
val response = connection.inputStream.bufferedReader().readText()
val json = JSONObject(response)
if (json.getBoolean("success")) {
val data = json.getJSONObject("data")
DeferredLinkData(
path = data.getString("path"),
queryParams = parseQueryParams(data.optJSONObject("queryParams")),
referrer = data.optString("referrer", null),
createdAt = data.getString("createdAt")
)
} else null
} else null
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
private fun parseQueryParams(json: JSONObject?): Map<String, String>? {
if (json == null) return null
val map = mutableMapOf<String, String>()
json.keys().forEach { key ->
map[key] = json.getString(key)
}
return map
}
}Activity integration:
// MainActivity.kt
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var deferredLinkService: DeferredLinkService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
deferredLinkService = DeferredLinkService(this)
// Handle direct App Links
handleIntent(intent)
// Check for deferred deep links on first launch
checkDeferredLink()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
if (Intent.ACTION_VIEW == intent.action) {
intent.data?.let { uri ->
navigateToPath(uri.path, parseQueryParams(uri))
}
}
}
private fun checkDeferredLink() {
lifecycleScope.launch {
val linkData = deferredLinkService.checkDeferredDeepLink()
linkData?.let {
navigateToPath(it.path, it.queryParams)
}
}
}
private fun navigateToPath(path: String?, params: Map<String, String>?) {
// Implement your navigation logic here
when {
path?.startsWith("/product/") == true -> {
val productId = path.substringAfterLast("/")
// Navigate to product screen
}
path?.startsWith("/profile/") == true -> {
val userId = path.substringAfterLast("/")
// Navigate to profile screen
}
// Add more routes as needed
}
}
private fun parseQueryParams(uri: Uri): Map<String, String> {
val params = mutableMapOf<String, String>()
uri.queryParameterNames.forEach { name ->
uri.getQueryParameter(name)?.let { value ->
params[name] = value
}
}
return params
}
}Alternative: Using Retrofit
If you're using Retrofit in your project:
// DeepTapApi.kt
interface DeepTapApi {
@GET("api/deferred-link")
suspend fun getDeferredLink(
@Query("subdomain") subdomain: String
): DeferredLinkResponse
}
data class DeferredLinkResponse(
val success: Boolean,
val data: DeferredLinkData?,
val error: String?
)
// Usage
val retrofit = Retrofit.Builder()
.baseUrl("https://deeptap.io/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val api = retrofit.create(DeepTapApi::class.java)
lifecycleScope.launch {
try {
val response = api.getDeferredLink("YOUR-SUBDOMAIN.deeptap.io")
if (response.success && response.data != null) {
navigateToPath(response.data.path, response.data.queryParams)
}
} catch (e: Exception) {
e.printStackTrace()
}
}✅ Best Practices
- • Check only on first app launch
- • Use SharedPreferences to track check status
- • Handle network errors gracefully
- • Set reasonable timeout (10 seconds)
- • Don't block UI on this check
⚠️ Limitations
- • Fingerprint matching is not 100% reliable
- • Links expire after 7 days
- • User must install from same network
- • VPNs/proxies may affect matching
- • One-time retrieval (link is consumed)
📱 Example Android App
Want to see a working implementation? Check out our complete Android App Links example application with full source code.
Android App Links Example
Complete working Android application demonstrating both custom scheme deep links and Android App Links implementation.
1. Clone the repository: git clone https://github.com/TedyHub/android-app-links-example.git
2. Open in Android Studio and run: ./gradlew installDebug
3. Test with ADB: adb shell am start -a android.intent.action.VIEW -d "example://link/123"