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:
- Declare that your app owns the link domain.
- Receive the tapped URL in your activity.
- Ask where that link points.
- Route to the screen.
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
| Value | Example | Where to find it |
|---|---|---|
| Link domain | links.example.com | Your project → Links → App setup |
| Package name | com.example.app | applicationId in build.gradle.kts |
| SHA-256 fingerprints | AB:CD:… | Play Console → App signing, plus your local keystores |
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.
Get your local fingerprints with:
# 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 SHA2561. Declare the domain
In AndroidManifest.xml, inside the activity that should receive links:
<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:
<uses-permission android:name="android.permission.INTERNET" />Three things here are easy to get wrong:
pathPrefixends with a slash.pathPrefix="/l"is a literal prefix match, so it also claims/login,/legaland/latest. Write/l/.launchMode="singleTop". Without it, a link tapped while your app is running creates a second copy of your activity instead of delivering to the one already open.- Only list hosts that resolve. Android verifies every host in an
autoVerifyfilter as a single group. If one fails, the whole filter fails and your working domain stops opening too. Never add awww.host with no DNS record. If you genuinely need two hosts, give each its own<intent-filter>block so one cannot take down the other.
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.
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:
- The
onNewIntentparameter’s nullability depends on yourandroidx.activityversion. Recent versions declare it non-null as shown. If your compiler reports a signature mismatch, your superclass declares itIntent?— change the parameter and null-check. resolveis asuspendfunction, so you need coroutines:implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"), plusimplementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")forlifecycleScope.
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:
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()
}
}
}What you get back
{ "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:
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 cardOptional: 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.
implementation("com.android.installreferrer:installreferrer:2.2")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:
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.
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
GET https://links.example.com/l/<slug>
Accept: application/jsonThe 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.
{ "matched": true, "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null, "slug": null, "deepLinkPath": null }| Field | Type | Meaning |
|---|---|---|
matched | bool | false = unknown, inactive, or deleted slug |
linkId | int | null | Our id for the link |
slug | string | null | The slug that matched |
deepLinkPath | string | null | The 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
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.
GET https://links.example.com/.well-known/assetlinks.jsonThey 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.
| Field | Example | What it does |
|---|---|---|
| Name | Summer sale | A label for your own reference in the dashboard. Never shown to users, never sent to your app. |
| Address | summer-sale | The slug. Produces https://links.example.com/l/summer-sale — this is the URL you share. |
| Opens in the app | /product/123 | The path your app routes to. Leave blank to open the home screen. |
| Desktop fallback | https://example.com/product/123 | Where laptops go. Phones without the app are sent to the App Store or Play Store. Defaults to your site. |
| Link is active | ticked | Untick 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:
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
- Changing a link needs no app release. Point
summer-saleat a different screen whenever you like; links already shared follow. That is the whole reason the URL carries a slug instead of a path. - Adding a link needs no configuration change either. Your verification files claim
/l/*, so every future slug is already covered — no waiting on Apple’s CDN, no Android re-verification. - Set the desktop fallback per link. Left blank it goes to your site root, which drops all context for anyone who clicks on a laptop.
- Share the URL without a trailing slash.
https://links.example.com/l/summer-sale, not.../summer-sale/.
Testing
# 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:
Domain verification state:
links.example.com: verifiedIf 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:
adb shell pm set-app-links-user-selection --user cur \
--package com.example.app true links.example.comYou can also check what Google’s own validator sees:
curl "https://digitalassetlinks.googleapis.com/v1/statements:list?\
source.web.site=https://links.example.com&\
relation=delegate_permission/common.handle_all_urls"Troubleshooting
- Android shows a chooser instead of opening the app. Verification has not passed. Check
adb shell pm get-app-links, and confirm the fingerprint in your dashboard matches the build you installed — a debug build needs the debug fingerprint listed. - Verification passed but links stop working after release. You are missing the Play App Signing fingerprint. Google re-signed your AAB with a different key than the one you listed.
- Links work on one device and not another. Verification runs at install time. If the domain was misconfigured when a device installed, that device keeps the failed result until the app is reinstalled or updated.
- The link opens the app but lands on the home screen. Your resolve call is returning null. Log the response code and content type; anything in the 3xx range means the request did not carry
Accept: application/json. getIntent()keeps returning the old link. You did not callsetIntent(intent)insideonNewIntent.
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.