Edok Studio Edok Studio
Guide

App Links on Android, step by step

Updated September 2, 2026 12 min read

This guide takes a native Android app from nothing to a link that opens the right screen. Kotlin, no third-party dependencies, and no SDK — App Links are an operating-system feature, and the work is one intent filter plus about forty lines of your own code.

Four steps, and only the first one needs an app release:

  1. Declare that your app owns the link domain.
  2. Receive the tapped URL in your activity.
  3. Ask where that link points.
  4. Route to the screen.
Every sample below uses links.example.com as the link domain. Replace it with the domain shown on your project’s Links → App setup tab — that tab also holds the package-name and fingerprint fields, and shows this same intent filter with your real values already filled in.

Before you start

ValueExampleWhere to find it
Link domainlinks.example.comYour project → Links → App setup
Package namecom.example.appapplicationId in build.gradle.kts
SHA-256 fingerprintsAB:CD:…Play Console → App signing, plus your local keystores
Enter these in the dashboard — they are what your assetlinks.json file is built from.

We host assetlinks.json for you at the right path with the right content type. There is nothing to upload and no web server to run.

The fingerprint that matters in production comes from Play Console → Test and release → App signing, not from your own keystore. Google re-signs every uploaded AAB, so an app that lists only its local keystore works all through testing and stops working the day it ships. Add all three: debug, upload, and Play App Signing.

Get your local fingerprints with:

Local keystore fingerprintsbash
# Debug
keytool -list -v -keystore ~/.android/debug.keystore \
  -alias androiddebugkey -storepass android | grep SHA256

# Your release keystore
keytool -list -v -keystore /path/to/release.jks -alias your-alias | grep SHA256

1. Declare the domain

In AndroidManifest.xml, inside the activity that should receive links:

app/src/main/AndroidManifest.xmlxml
<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTop">

    <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="links.example.com"
              android:pathPrefix="/l/" />
    </intent-filter>

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

You also need internet access for the resolve call in step 3:

AndroidManifest.xml — above <application>xml
<uses-permission android:name="android.permission.INTERNET" />

Three things here are easy to get wrong:

autoVerify makes Android fetch https://links.example.com/.well-known/assetlinks.json at install time and compare your signing fingerprint against it. If it matches, taps go straight to your app with no chooser. Google’s own checker for that file is the Digital Asset Links API.

2. Receive the URL

Two entry points, and you need both. A cold start arrives through onCreate; a tap while your app is already running arrives through onNewIntent.

MainActivity.ktkotlin
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        handleIntent(intent)                    // cold start
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)                       // keep getIntent() in sync
        handleIntent(intent)                    // already running
    }

    private fun handleIntent(intent: Intent?) {
        if (intent?.action != Intent.ACTION_VIEW) return
        val uri = intent.data ?: return
        lifecycleScope.launch {
            val path = EdokLinks.resolve(uri)
            if (path != null) route(path) // else: stay on the current screen
        }
    }
}

setIntent(intent) inside onNewIntent is not optional. Skip it and any later call to getIntent() still returns the intent that originally launched the activity, which is a genuinely confusing bug to chase. route() lives in the same activity — see step 4.

Two notes on compiling this:

3. Resolve the link

A Smart Link URL carries a slug, not a destination — /l/spring-sale, not /product/123. That indirection is the point: you can re-point a link that is already out in the world without shipping an app update. It also means that when your app is installed, the operating system hands the URL straight to your app and never contacts us, so your app has to ask where the slug points.

Request the link URL itself with Accept: application/json:

EdokLinks.ktkotlin
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL

object EdokLinks {

    private const val TIMEOUT_MS = 4_000

    /**
     * Returns the in-app path for [url], or null if it cannot be resolved.
     * Never throws — a link tap needs an answer or a null.
     */
    suspend fun resolve(url: Uri): String? = withContext(Dispatchers.IO) {
        var connection: HttpURLConnection? = null
        try {
            connection = (URL(url.toString()).openConnection() as HttpURLConnection).apply {
                requestMethod = "GET"
                setRequestProperty("Accept", "application/json")
                // A 3xx means we were answered as a browser. That response has
                // no destination in it, so following it is a wasted request.
                instanceFollowRedirects = false
                connectTimeout = TIMEOUT_MS
                readTimeout = TIMEOUT_MS
            }

            if (connection.responseCode != 200) return@withContext null

            val contentType = connection.contentType ?: ""
            if (!contentType.contains("json")) return@withContext null

            val body = connection.inputStream.bufferedReader().use { it.readText() }
            val json = JSONObject(body)

            // matched:false is the normal answer for a slug that is unknown,
            // switched off, or deleted. A link can outlive whoever shared it.
            if (!json.optBoolean("matched", false)) return@withContext null

            json.optString("deepLinkPath").takeIf { it.isNotEmpty() }
        } catch (_: Exception) {
            // No connectivity, DNS failure, TLS error, a captive portal serving
            // HTML — all of it must land the user in the app, never on a crash.
            null
        } finally {
            connection?.disconnect()
        }
    }
}
Keep the timeout short — four seconds is plenty. This request sits between a tap and a screen. A slow answer is worse than no answer: when it expires, show your home screen and move on.

What you get back

Two possible answersjson
{ "matched": true,  "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null,  "slug": null,          "deepLinkPath": null }

matched: false is the normal answer for a slug that is unknown, switched off, or deleted. A link outlives whoever shared it and people do tap old ones, so treat it as “could not open this”, never as an error.

4. Route

In your activity:

MainActivity.ktkotlin
private fun route(path: String) {
    // Treat path as untrusted input: match it against routes you know rather
    // than passing segments straight into a query.
    val segments = path.trim('/').split('/')
    when {
        segments.size == 2 && segments[0] == "product" ->
            startActivity(Intent(this, ProductActivity::class.java)
                .putExtra("id", segments[1]))
        else -> Unit   // unknown route: stay where you are
    }
}

If your app has a sign-in or onboarding flow, hold the path rather than dropping it. A link routinely arrives before there is anywhere to send it, and the destination should open once the user gets through.

Host your link domain and assetlinks.json in Edok Studio

Design once with device frames and export every required App Store & Google Play size in one ZIP — watermark-free on the free plan.

Start free — no card

Optional: land new installs on the right screen

Without this, somebody who taps a link, installs from the Play Store and opens your app arrives at your home screen — the tap is lost across the store visit. Android is the platform where this can be fixed exactly rather than guessed at, because the Play Store carries a referrer through the install.

build.gradle.ktskotlin
implementation("com.android.installreferrer:installreferrer:2.2")
InstallClaim.ktkotlin
import android.content.Context
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/** Call once, on the first run after install. */
fun claimInstall(context: Context, onResolved: (String?) -> Unit) {
    val prefs = context.getSharedPreferences("edok", Context.MODE_PRIVATE)
    if (prefs.getBoolean("install_claimed", false)) return

    val client = InstallReferrerClient.newBuilder(context).build()
    client.startConnection(object : InstallReferrerStateListener {
        override fun onInstallReferrerSetupFinished(responseCode: Int) {
            prefs.edit().putBoolean("install_claimed", true).apply()
            if (responseCode != InstallReferrerClient.InstallReferrerResponse.OK) return

            // getInstallReferrer throws RemoteException if the service died
            // between connecting and reading.
            val referrer = try {
                client.installReferrer.installReferrer   // "edok_link=AbC123..."
            } catch (_: Exception) {
                null
            } finally {
                client.endConnection()
            }
            if (referrer.isNullOrEmpty()) return

            CoroutineScope(Dispatchers.IO).launch {
                onResolved(EdokLinks.claimInstall(referrer))
            }
        }

        override fun onInstallReferrerServiceDisconnected() {}
    })
}

Add this alongside resolve inside the same EdokLinks object, so it can share TIMEOUT_MS:

EdokLinks.kt — second functionkotlin
suspend fun claimInstall(referrer: String): String? = withContext(Dispatchers.IO) {
    var connection: HttpURLConnection? = null
    try {
        val endpoint = URL("https://admin.edokstudio.com/api/public/links/claim-install")
        connection = (endpoint.openConnection() as HttpURLConnection).apply {
            requestMethod = "POST"
            doOutput = true
            setRequestProperty("Content-Type", "application/json")
            connectTimeout = TIMEOUT_MS
            readTimeout = TIMEOUT_MS
        }
        connection.outputStream.use {
            it.write(JSONObject().put("referrer", referrer).toString().toByteArray())
        }

        if (connection.responseCode != 200) return@withContext null
        val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
        if (!json.optBoolean("matched", false)) return@withContext null
        json.optString("deepLinkPath").takeIf { it.isNotEmpty() }
    } catch (_: Exception) {
        null
    } finally {
        connection?.disconnect()
    }
}

Call it once and remember that you have. Google keeps the referrer available for 90 days, but repeat calls buy you nothing. matched: false means an ordinary install that did not come from a link — show your home screen.

This needs a Play Store URL set in your app-link config. Without one we do not attach a referrer to the store link, and every claim returns matched: false. There is no iOS equivalent, from anyone — see the iOS guide for why.

API reference

Everything your app talks to. No API key and no authentication — these endpoints are public, because an app installed on someone’s phone has no credentials it could safely present.

Resolve a link

Requesthttp
GET https://links.example.com/l/<slug>
Accept: application/json

The same URL a browser opens. The Accept header is the whole difference: without it you get the human-facing interstitial page and a redirect to the store.

Response — 200 in both casesjson
{ "matched": true,  "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null,  "slug": null,          "deepLinkPath": null }
FieldTypeMeaning
matchedboolfalse = unknown, inactive, or deleted slug
linkIdint | nullOur id for the link
slugstring | nullThe slug that matched
deepLinkPathstring | nullThe path to route to, e.g. /product/123

Do not follow redirects on this request. A 3xx means you were answered as a browser — that response has no destination in it, and following it costs a second round trip to reach the same nothing.

Claim an install

Requesthttp
POST https://admin.edokstudio.com/api/public/links/claim-install
Content-Type: application/json

{ "referrer": "<the full Play install referrer string>" }

Same response shape as resolve, so one routing function serves both.

Verification files

We serve these for you; you never upload anything.

http
GET https://links.example.com/.well-known/assetlinks.json

They return application/json with no file extension and no redirect — exactly what Apple and Google require. Open them in a browser to see what they see.

Creating a link

In Edok Studio, open your project → Links+ New link.

FieldExampleWhat it does
NameSummer saleA label for your own reference in the dashboard. Never shown to users, never sent to your app.
Addresssummer-saleThe slug. Produces https://links.example.com/l/summer-sale — this is the URL you share.
Opens in the app/product/123The path your app routes to. Leave blank to open the home screen.
Desktop fallbackhttps://example.com/product/123Where laptops go. Phones without the app are sent to the App Store or Play Store. Defaults to your site.
Link is activetickedUntick to switch a link off without deleting it.

“Opens in the app” is the exact string your app receives. Whatever you type there comes back as deepLinkPath and lands in your routing function unchanged:

The whole round trip
Dashboard:  Opens in the app  →  /product/123
Resolve:    {"matched": true, "deepLinkPath": "/product/123"}
Your code:  route("/product/123")  →  ProductActivity, id = "123"

So it has to be a path your router already understands. There is no translation layer — pick the same paths your app uses internally.

Things worth knowing

Click counts read lower than reality, and that is expected. When somebody already has your app, the operating system opens it directly. Those taps never reach our servers — or anyone else’s — so no product in this category can count them.

Testing

adbbash
# Is the domain verified on this device?
adb shell pm get-app-links com.example.app

# Fire a link
adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://links.example.com/l/your-slug"

You want:

Expected output
Domain verification state:
  links.example.com: verified

If it says verified but links still open a browser, look further down the same output at the per-user selection. On emulators and some dev images it can be Disabled even after verification succeeded:

Force the per-user selection onbash
adb shell pm set-app-links-user-selection --user cur \
  --package com.example.app true links.example.com

You can also check what Google’s own validator sees:

Digital Asset Links APIbash
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?\
source.web.site=https://links.example.com&\
relation=delegate_permission/common.handle_all_urls"

Troubleshooting

Building for more than one toolchain? See the native iOS guide and the Flutter guide, or the platform-level explanation of why none of this needs an SDK.

Frequently asked questions

Why does Android show a chooser instead of opening my app?

Domain verification has not passed. Run adb shell pm get-app-links <package> — you want "Domain verification state: yourdomain: verified". The usual cause is a fingerprint mismatch: the SHA-256 listed in assetlinks.json is not the key that signed the build you installed. A debug build needs the debug fingerprint listed; a Play release needs the Play App Signing fingerprint.

Which SHA-256 fingerprint goes in assetlinks.json?

All of them — debug, upload and Play App Signing. The one that matters in production comes from Play Console → Test and release → App signing, not from your own keystore, because Google re-signs every uploaded AAB. An app that lists only its local keystore fingerprint works all the way through testing and stops working the day it ships.

Why did my App Links stop working after a release?

You are almost certainly missing the Play App Signing fingerprint. Google re-signed your AAB with a different key than the one you listed, so verification now fails for every user who installs from the Play Store even though your internal builds still work.

Does deferred deep linking work on Android?

Yes, and it is exact rather than probabilistic. The Play Store carries an install referrer through the install, so a user who taps a link, installs and opens your app can be sent to the screen the link pointed at. Read it once with the Play Install Referrer library and exchange it for a path. It needs a Play Store URL configured on your link, otherwise no referrer is attached and every claim returns matched: false.

Should pathPrefix be /l or /l/?

Write /l/ with the trailing slash. pathPrefix is a literal prefix match, so /l also claims /login, /legal and /latest — every URL on the domain that happens to start with those two characters, which will hand your app links it has no route for.

Keep reading