Mobile attribution and deferred deep linking for Android. Distributed as a binary AAR —
package io.linktrail, entry point LinkTrail. The counterpart of the
LinkTrail iOS SDK.
- Artifact:
io.linktrail:sdk:0.1.0(Maven Central) · Min SDK: 26 - Docs: docs.linktrail.io · Release notes: GitHub releases
The SDK is published to Maven Central, so no custom repository is needed — just add the dependency:
// app/build.gradle.kts
dependencies {
implementation("io.linktrail:sdk:0.1.0")
}mavenCentral() is already in the default repositories of every Android project, which also
resolves the SDK's transitive dependencies (coroutines, Play Install Referrer, App Set ID). Keep
google() alongside it.
Upgrading from 0.0.5 or earlier? Read Upgrading to 0.1.0 first: 0.1.0 has breaking changes.
import io.linktrail.LinkTrail
import io.linktrail.LinkTrailOptions
// In Application.onCreate(). The API key is required — a blank key throws.
LinkTrail.configure(context = this, apiKey = "lt_live_…")
// One hook handles both first-launch (deferred) AND re-engagement links:
LinkTrail.shared?.onLink { link, source ->
router.route(link.path, link.customData) // e.g. "/products/aj1" + { voucher: SUMMER25 }
}
// Observe failures if you want:
LinkTrail.shared?.onError { error -> /* e.g. LinkTrailError.InvalidApiKey (401) */ }The install is tracked automatically by configure (held until the user's consent decision, see
below). Forward incoming links from your Activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
route(intent?.data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
route(intent.data)
}
private fun route(uri: Uri?) {
if (LinkTrail.shared?.handleDeepLink(uri) == true) return // a LinkTrail link: onLink fires
// Not a LinkTrail link (your own myapp:// links, OAuth callbacks, ...): handle it yourself.
}Every callback API also has a coroutine suspend twin (trackInstallAsync, handleDeepLinkAsync,
trackEventAsync). Callbacks are delivered on the main thread.
- A repeat call with the same key and equal options is a no-op that returns the existing instance.
- A call with a different key or options shuts the previous instance down (its pending work,
queue flushes and callbacks are dropped) and returns a new one. Re-register
onLink,onAttributionandonErroron the new instance (LinkTrail.shared). - Don't keep a reference to an old instance: calls on a replaced instance are ignored (no request,
no callback;
handleDeepLinkreturnsfalse). - Persisted state (install flags, consent, device id) is untouched by a reconfigure.
- Configure the SDK in one process only (normally your main process).
Consent gating is on by default (requireConsent = true), for GDPR / ePrivacy. The SDK holds
until the user decides:
- Before
setConsent: nothing is sent. The install is held (not lost), and events are dropped. setConsent(true): the install is sent withconsent = true. It is counted and attributed, the deferred link is routed throughonLink, and queued events are flushed.setConsent(false): one uncounted install is sent withconsent = falseand no device id. The backend resolves the deferred link so the user still lands on the right screen, and records nothing. Events are dropped. The denial isn't re-sent on later launches.
LinkTrail.configure(context, "lt_live_…") // install held until a decision
// … once your consent UI resolves:
LinkTrail.shared?.setConsent(granted = true) // counted + attributed install, routes, flushes events
// LinkTrail.shared?.setConsent(granted = false) // one uncounted install, no device id; still routesGranting after a denial, on the same launch or a later one, sends the counted install then and routes the deferred link if the denial couldn't. A deferred link is routed at most once, so a user who already reached their screen is never re-routed. Re-engagement links always route; without consent they use a non-recording resolve. The decision persists across launches (on this device, see Data and privacy).
To attribute at init without waiting for an answer, opt out: LinkTrailOptions(requireConsent = false); an explicit setConsent(false) still wins. (Separately, autoTrackInstall = false lets you
send the install yourself via trackInstall(); it is still held until setConsent, and force
never skips the hold.)
Android gets deferred linking the deterministic way: the Play Install Referrer supplies a click
token on first launch, which the SDK sends with the install. You can register onLink whenever your
app is ready to route, for example after a splash or login screen. The deferred link is delivered
at least once:
- When the install response carries a link, the SDK saves it to disk straight away.
- If
onLinkis already set, the link is delivered at once. Otherwise it is delivered (on the main thread, withsource = DEFERRED) as soon asonLinkis set, in the same launch or a later one, even if the app was killed in between. - The link is marked routed only after your handler has been called. If your handler crashes, the link may be delivered once more on the next launch, so make routing idempotent.
- A saved link that hasn't been delivered within 7 days is dropped.
- The install is never re-sent for this; only the delivery is retried.
The SDK keeps its state on the device only: the install and deferred-link flags, the consent answer,
the device id, the cached attribution and the offline event queue. It is one small file in the app's
noBackupFilesDir.
Android Auto Backup and device-to-device transfer never copy that folder, so:
- A reinstall restored from backup, or a new phone, starts fresh. The install is sent again and its deferred link is routed; the new device gets its own device id.
- Consent stays with the device. After a restore the consent answer is gone too, so the SDK holds the install again. Show your consent UI whenever the SDK has no answer, not only on the very first launch.
- No setup is needed. You don't need
fullBackupContentordataExtractionRulesrules for the SDK, and yourandroid:allowBackupsetting doesn't affect it. If you keep your own copy of the consent answer in backed-up storage, exclude it too, or it will disagree with the SDK after a restore.
// Custom post-install events:
LinkTrail.shared?.trackEvent("purchase", value = 59.99, currency = "USD")
// Cached results:
val attribution = LinkTrail.shared?.lastAttribution
val lastLink = LinkTrail.shared?.lastDeepLink
// Attribution stream (fires when an install is attributed):
LinkTrail.shared?.onAttribution { attribution -> /* … */ }LinkTrailOptions also takes logEnabled, logLevel, requestTimeoutMillis, retryPolicy,
autoTrackInstall and linkDomains (see Which links the SDK claims).
Invalid values (maxAttempts < 1, a delay or requestTimeoutMillis <= 0) are clamped with a
WARNING, never thrown. (App Tracking Transparency / SKAdNetwork are iOS-only and have no Android
equivalent.)
Custom events that fail with a transport error, 408, 429 or 5xx are queued and retried. Any other
status (400, 401, 403, 404, 409, 422, …) is reported to onError and dropped.
Declare your App Links host in the manifest:
<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="kick.linktrail.io" />
</intent-filter>Android verifies it against a Digital Asset Links file at https://<host>/.well-known/assetlinks.json
listing your package + signing-cert SHA-256. LinkTrail hosts this file for your link domains, built
from the app you register in the dashboard.
A custom scheme (myapp://…) is your own routing: the SDK only claims https LinkTrail links, so
handle custom-scheme intents yourself. To open a LinkTrail link in the app, use its https URL.
Links opening the browser or Play Store instead of your installed app? That's almost always App Links verification — see TROUBLESHOOTING.md for the diagnosis commands, signing-certificate pitfalls, and the two caches involved.
handleDeepLink returns true only for a LinkTrail link, which needs all three:
- the scheme is
https(httpis accepted in debuggable builds only); - the host (case, port and a trailing dot ignored; internationalised names compared in punycode) is
linktrail.ioor a subdomain of it, or alinkDomainsentry or a subdomain of it. A URL whose authority contains\,%, whitespace or a control character, has a bad port, or whose host isn't a valid host name, is never claimed; - the path has a slug (
https://kick.linktrail.io/alone is not a link).
Anything else, such as mailto:, file:, your own myapp://settings or an OAuth callback like
https://accounts.example.com/cb?code=…, returns false (handleDeepLinkAsync throws
LinkTrailError.NotALinkTrailUrl). No request is sent and onError is not called. An empty
linkDomains (the default) means only *.linktrail.io links are claimed, not every link.
If your links use a custom domain, list it at configure, or its links won't be claimed:
LinkTrail.configure(context, "lt_live_…", LinkTrailOptions(linkDomains = listOf("go.example.com")))Entries are normalised ("https://Go.Example.com:443/x" becomes "go.example.com"); empty or
invalid ones are ignored with a warning. Order and duplicates don't matter.
0.1.0 has breaking changes (a 0.x minor bump). Check each one against your integration:
-
Custom link domains must be listed in
linkDomains.handleDeepLinknow claims onlyhttpslinks onlinktrail.io(or a subdomain) or on alinkDomainsentry (or a subdomain). Before, an emptylinkDomainsmeant "handle every link". Links on*.linktrail.ioneed no change; for a custom domain:// 0.0.5: LinkTrailOptions() (empty linkDomains handled every link) // 0.1.0: LinkTrailOptions(linkDomains = listOf("go.example.com"))
Other URLs (
mailto:, OAuth callbacks, your ownmyapp://links) now returnfalse, so route them yourself. -
A denied install carries no device identifier and is sent exactly once.
setConsent(false)wins even withrequireConsent = false. -
trackInstall(force = true)no longer bypasses the consent hold. -
configureis a singleton. Re-register callbacks after a reconfigure. Seeconfigureis a singleton. -
Log format. Lines read
[LinkTrail] [LEVEL] <message>(WARNING, notWARN), with no query strings and a redacted referrer token. Update any Logcat filters. -
SDK state is no longer backed up. It moves to device-only storage on the first
configure(in-place updates keep their state). A restored or new phone starts fresh and must ask for consent again. See Data and privacy. -
Invalid options are clamped, not thrown, and only retryable event failures (transport, 408, 429, 5xx) are queued.
Full notes: v0.1.0 release.
Reset the SDK in a debug build to replay a first launch:
// In Application.onCreate(), before LinkTrail.configure:
if (BuildConfig.DEBUG) {
LinkTrail.resetForTesting(this) // clears install flags, cached attribution, saved deferred link, queue, consent and device id
}
LinkTrail.configure(this, "lt_live_…")
// The reset cleared the consent answer: call setConsent again, or the install stays held.Then create a link in the LinkTrail dashboard, open it once on the device (registers a click), and
launch the app: onLink should fire with source = DEFERRED and that link's path. Turn on
LinkTrailOptions(logEnabled = true) and watch adb logcat -s LinkTrail.
Or clear the whole app: adb shell pm clear <applicationId>.
Faking the Play Install Referrer (debuggable builds only). An adb install has no Play Install
Referrer, so the deterministic deferred path can't run on an emulator. A debuggable app can stand in
a referrer string instead; the SDK parses it like Play's:
adb shell setprop debug.linktrail.referrer "lt_click=<32-hex token>" # before the app's first launch
adb shell setprop debug.linktrail.referrer "" # off again (Play is asked as usual)- Read only for the install (the first launch after install,
pm clearorresetForTesting). - A value without a valid
lt_click(32 lowercase hex) gives no token, like an organic referrer. - Logged as a WARNING with the token redacted. A
<meta-data android:name="LINKTRAIL_REFERRER">entry in a debug-only manifest works too; the system property wins.
Not configurable in release builds. Release builds never read the referrer override and always talk to LinkTrail's production backend; there is no base-URL option.
More: docs.linktrail.io and TROUBLESHOOTING.md.
example/ is KickFlip, a small Jetpack Compose storefront that shows deferred
deep linking end to end — it consumes this exact AAR, the same way your app would. A debug panel
fires the four scenarios (home · category · product · product + voucher):
cd example && ./gradlew :app:installDebugAdd your key to example/local.properties (linktrail.apiKey=lt_live_…) to run against the live
backend; without one it routes the simulator's links locally. See example/README.md.
Hit a bug, or something not behaving as documented? Everyone is encouraged to open an issue — no problem is too small, and a quick report helps us make the SDK better for everyone. Picking a template walks you through the details we need. For App Links / deep-linking problems, the troubleshooting guide is often the fastest fix.
Apache License 2.0. See LICENSE.