Skip to content

About

Strait SDK for Android (Kotlin, via JitPack): App Links, deferred deep links with the Play Install Referrer, and link-open analytics.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Strait

Strait SDK for Android

Straight to the screen. On the record.
A tap opens the exact screen, and every link open and install is recorded in your Strait dashboard.

Latest version SDK: Beta CI Licence: MIT

Docs · Platform status · Changelog · Talk to the Strait team

strait-sdk-android (Kotlin) · v0.10.2

Deep linking for native Android, part of Strait. It covers direct links (verified App Links and custom-scheme hand-offs), deferred links (Play Install Referrer, falling back to a fingerprint match), app-state labelling, analytics events, open reporting (every link open recorded once, with an offline retry queue) and the fingerprint debug check.

The library is pure JVM: every Android piece (Intents, lifecycle, Install Referrer, SharedPreferences, display metrics, HTTP) is injected through StraitConfig, so gradle test checks the logic on a plain JVM against the shared vectors. It is a 1:1 port of the React Native reference (sdk-react-native/src/core.ts + strait.ts).

Dependencies: Kotlin stdlib only. org.json is compileOnly because Android already ships it. Outside Android, add org.json:json yourself.

Set it up with one command

The 5-minute path. In your app's folder (Android, Kotlin; Node 18.17 or newer, nothing to install first), with the publishable key from Dashboard → Get started:

npx @straitlink/cli init --key st_pub_live_… --host <your-handle>.strait.link --dry-run   # show what it will write
npx @straitlink/cli init --key st_pub_live_… --host <your-handle>.strait.link             # write it

It finds your app from its files, writes StraitLinks.kt next to your app code with your key, link address and an onLink handler, and prints what's left for you: the Gradle lines, the intent filters and what to fill in under Dashboard → Apps & stores. It never edits your existing files. Then check that your link domain lists your app:

npx @straitlink/cli doctor --host <your-handle>.strait.link --android <your.package>

Rather do it by hand? Follow the quick start below.

Quick start (60 seconds)

1. Install. Add JitPack and implementation("com.github.strait-link:strait-sdk-android:<version>"), as in Install.

2. Check your key and a link. In Dashboard → Links, make a link with the path summer and the destination https://shop.example.com/product/42. Then ask Strait about it the way this SDK will:

curl -s https://<your-handle>.strait.link/v1/resolve -H 'Content-Type: application/json' \
  -d '{"publishableKey":"st_pub_live_…","url":"https://<your-handle>.strait.link/summer","platform":"android"}'

You should see: {"matched":true,"longUrl":"https://shop.example.com/product/42",…}. The key comes from Dashboard → Get started and is safe in apps. "reason":"not_found" means the key is from another workspace or the path is wrong. The check counts as one tap in Dashboard → Analytics.

3. Wire it. Create one StraitClient in your Application and feed it links from your Activity, as in Android integration. The Android guide has the intent filters and an adb command that brings your app to the front.

Platform features

Feature Status Notes
Direct links: verified App Links and custom schemes ● Live
Deferred links through the install ● Live Play Install Referrer first, then a fingerprint match.
Open reporting, analytics events, app-state labels ◐ Beta Every link open recorded once, with an offline retry queue.
Store sheet: Google Play inside your app New Keeps the deep link through the install.
iPhone install matching Opt-in Off until you choose it; proven on iPhone simulators, the real-iPhone run waits on our App Store listing. In the iOS and cross-platform SDKs, not this package. Your choice in Dashboard → Settings → iPhone installs: device matching, paste handoff, both or neither, read from Strait at runtime. Device matching is off by default. iPhone matches are labelled Estimated until measured.
Published on Maven Central – Not yet JitPack builds it from this repo's version tags.

● Live · ◐ Beta · ○ Planned · – Not yet · Opt-in: off until you switch it on · New: recently shipped. The same words as the platform status page.

Install

Published on JitPack from this repo's version tags.

// settings.gradle.kts
dependencyResolutionManagement {
    repositories { google(); mavenCentral(); maven("https://jitpack.io") }
}

// app/build.gradle.kts
dependencies { implementation("com.github.strait-link:strait-sdk-android:v0.10.2") }

Contract behaviours (Strait SDK contract)

# Status Where
B1 publishableKey in every body (never appId or the secret key) ✓ StraitClient
B2 browserScreenWidth = ceil(w - 0.001) ✓ Core.kt, vectors
B3 short link → POST /v1/resolve {publishableKey,url,platform}, engine reason reported ✓ StraitClient.handleUrl/start
B4 classifyUrl (custom scheme → https destination; strait_click removed via takeClickId, returned as clickId) ✓ Core.kt, vectors
B5 closed / AppStateTracker (2000 / 1000 ms) ✓ Core.kt, vectors
B6 deferred once per install (strait.deferredChecked), skipped but marked when launched by a link; marked only once the engine answered (no answer / 429 / 5xx → reason:"network", retried next launch); checkDeferred() sends no openId ✓ StraitClient.start
B7 referrer strait_link → /v1/referrer {linkId, clickId, openId, at}, else / on miss → /v1/match {…device, openId, at} (same openId) ✓ StraitClient
B8 iOS fingerprint n/a (Android). /v1/match sends the device fields
B9 one LinkEvent shape, replay to late subscribers, onLinkStart with the same id ✓ StraitClient
B10 never throws; network failure → matched:false, reason:"network" ✓ StraitClient
B11 JSON built by an encoder (org.json / escaped builders) ✓ StraitClient, Strait.build*Body
B12 splitUrl without Uri/URI (lower-cased, +/%xx decoded, fragment dropped) ✓ Core.kt, vectors
B6a reinstall-safe first open: installTime stored as strait.installTime, firstLaunchState ✓ StraitClient.start, Core.kt, vectors v10
B13 trackEvent, reportFingerprint (origin app), compareFingerprint ✓ StraitClient
B13a trackEvent(eventId = …) (else newEventId), offline events kept in strait.pendingOpens and sent to /v1/event ✓ StraitClient
B14 every open reported once (newOpenId = event id), retry queue strait.pendingOpens (pruneOpenQueue, shouldRetryReport), pendingOpenReports() / flushOpenReports(), backoff after a 429/5xx (retryDelayMs, C1); deviceClass on resolve / open / store sheet (deviceClassForSize, sw600dp) ✓ StraitClient, Core.kt, vectors v11
B15 conversion events carry the tap id (strait.lastTap, eventClickId, 7-day ATTRIBUTION_WINDOW_MS, explicit clickId override) ✓ StraitClient, Core.kt, vectors
B16 every attributed open supplies the tap id (/v1/resolve, /v1/match, /v1/referrer reply clickId → strait.lastTap, replyClickId) ✓ StraitClient, Core.kt, vectors v4
B17 screenWidth is the portrait (shorter-side) width: portraitScreenWidth(w, h) ✓ Core.kt, README device, vectors v5
B18 open reports and the retry queue carry no query or fragment (reportUrl: host + path, plus the first utm_source); an expired strait.lastTap is deleted (staleTap) ✓ StraitClient, Core.kt, vectors v6
B22 old Firebase *.page.link links: short link → /v1/resolve (engine matches the old host + code); long link /?link=<url> → the link value is the destination, read on the device, no lookup ✓ Core.kt (pageLinkLongLink, classifyUrl), vectors v8
B23 doctor() setup check: key, endpoint, the link host's assetlinks.json lists the package and this build's signing certificate (doctorFindings, ids and wording from shared-spec ADR 0002); one GET, never throws, prints each finding ✓ Doctor.kt, StraitClient.doctor

Vectors: src/test/resources/test-vectors.json (signature) and conformance-vectors.json v8 (pure helpers). Both are byte-identical copies from shared-spec, so don't edit them here. doctor-vectors.json (B23, 43 cases) is the same file in the Flutter SDK, generated from the React Native SDK's doctorFindings.

Android integration

Calls that hit the network block the calling thread. Pass a background executor so start/handleUrl never run on the main thread. Listeners are called on that executor, so post to the main thread before you navigate.

1. Create the client once (Application)

class App : Application() {
    lateinit var strait: StraitClient

    override fun onCreate() {
        super.onCreate()
        val prefs = getSharedPreferences("strait", MODE_PRIVATE)
        strait = StraitClient(StraitConfig(
            publishableKey = "st_pub_live_…",                       // Dashboard → Get started
            endpoint = "https://<your-handle>.strait.link",
            linkHosts = listOf("https://go.yourbrand.com"),          // custom short-link domains (coming soon)
            storage = object : KeyValueStore {
                override fun get(key: String) = prefs.getString(key, null)
                override fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() }
            },
            installReferrer = { readInstallReferrer(this) },
            installTime = { packageManager.getPackageInfo(packageName, 0).firstInstallTime }, // reinstalls, below
            appVersion = { packageManager.getPackageInfo(packageName, 0).versionName },          // "which app version?"
            device = {
                val m = resources.displayMetrics
                DeviceFields(
                    screenWidth = portraitScreenWidth(m.widthPixels / m.density.toDouble(), m.heightPixels / m.density.toDouble()),
                    pixelRatio = m.density.toDouble(),
                    language = java.util.Locale.getDefault().toLanguageTag(),
                    timezone = java.util.TimeZone.getDefault().id,
                )
            },
            executor = java.util.concurrent.Executors.newSingleThreadExecutor(),
        ))

        // App state for B5: ProcessLifecycleOwner (androidx.lifecycle:lifecycle-process)
        ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleEventObserver { _, e ->
            when (e) {
                Lifecycle.Event.ON_RESUME -> strait.onAppState(AppLifecycleState.ACTIVE)
                Lifecycle.Event.ON_PAUSE -> strait.onAppState(AppLifecycleState.INACTIVE)
                Lifecycle.Event.ON_STOP -> strait.onAppState(AppLifecycleState.BACKGROUND)
                else -> Unit
            }
        })
    }
}

ProcessLifecycleOwner delays ON_PAUSE/ON_STOP by about 700 ms. If you need the brief pause that wraps link delivery to be reported exactly, also forward your link Activity's onPause/onResume. The tracker treats pauses shorter than 1000 ms as foreground either way.

Reinstalls and Auto Backup (installTime, contract B6a). Android backs up SharedPreferences (Auto Backup is on by default) and puts them back when the app is installed again. Without installTime, the restored "deferred check done" flag makes a reinstall from a link skip its deferred link and go uncounted. With it, the SDK stores the install time next to the flag and runs the check again when firstInstallTime changed. An app update keeps firstInstallTime, so it is never a new install; an app updated from an older SDK adopts the time without a new check. Optional extra: keep the Strait prefs file (strait above) out of backups:

<!-- res/xml/data_extraction_rules.xml (Android 12+, android:dataExtractionRules) -->
<data-extraction-rules>
  <cloud-backup><exclude domain="sharedpref" path="strait.xml"/></cloud-backup>
  <device-transfer><exclude domain="sharedpref" path="strait.xml"/></device-transfer>
</data-extraction-rules>
<!-- res/xml/backup_rules.xml (Android 11 and lower, android:fullBackupContent) -->
<full-backup-content><exclude domain="sharedpref" path="strait.xml"/></full-backup-content>

2. Feed it links (Activity)

This uses ComponentActivity, which new Android Studio projects already have. If yours doesn't, add implementation("androidx.activity:activity:1.9.3"). Already on AppCompatActivity? Keep it: it works the same.

import androidx.activity.ComponentActivity

class MainActivity : ComponentActivity() {
    private val strait get() = (application as App).strait

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        strait.onLinkStart { runOnUiThread { showOpeningLink() } }
        strait.onLink { e -> runOnUiThread { if (e.matched) navigate(e.path, e.params) else hideOpeningLink() } }
        // Launched from closed: the intent's URL (or null). Runs the deferred check once per install.
        if (savedInstanceState == null) strait.start(intent?.dataString)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        intent.dataString?.let(strait::handleUrl)   // app was running: background / foreground
    }
}

LinkEvent fields: id, kind (direct|deferred), route (app_link|custom_scheme|install_referrer|fingerprint), appState (closed|background|foreground), matched, reason, rawUrl, url, path, params, linkId, ms, at, referralCode, match. referralCode (deferred links only) is the referral code the tap carried, when the engine sends one; referrals are a preview and not switched on yet (contract B21). Grant rewards from your server via the referral.converted webhook. match (matched deferred links only) says why the install was attributed, e.g. MatchInfo("fingerprint", "probable") (exact for install_referrer); null when not matched. onLink replays past events to late subscribers. Both onLink and onLinkStart return an unsubscribe function.

What Strait records automatically (no extra code)

Every time a link opens the app, the SDK reports it once (contract B14). Open reports and the store sheet also send deviceClass (phone or tablet): by default the SDK reads smallestScreenWidthDp from the system configuration (600 dp or more = tablet, the standard sw600dp rule). To set it yourself, pass deviceClass = deviceClassForSize(resources.configuration.smallestScreenWidthDp.toDouble()) (or null to send nothing) to StraitConfig. Older engines ignore it.

Every recorded open and install (/v1/resolve, /v1/open, /v1/referrer, /v1/match; a saved retry keeps the versions of the build that made it) also sends sdkVersion (this SDK's version) and, when you pass appVersion, your app's versionName (trimmed, at most 64 characters, left out unless plain ASCII), so support can see which build a report came from. Events and /v1/lookup send neither; while setEnabled(false) nothing is sent.

How the app opened Reported via Joined to
Verified link tapped in WhatsApp, Gmail, Messages… /v1/resolve (the lookup is the report) the link; also counted as a tap
Browser handed off to the app (yourapp://…) /v1/open the exact tap (strait_click, removed before your app sees the URL)
First open after a Play install /v1/referrer the exact tap that sent the user to the store
First open with no Play referrer link /v1/match the matched tap
Your own https links /v1/open host + path (plus utm_source, if any); the query and fragment never leave the device (B18)

Reports that can't be sent (offline, server busy) are saved in storage under strait.pendingOpens and retried on the next start, whenever onAppState(ACTIVE) is called, and after any report that gets through, for up to 7 days (max 100). When the server is busy (429 or 5xx) the SDK waits before sending the saved ones again: 15–30 s, doubling each time in a row up to 1 hour, and at least as long as the server's Retry-After. The engine de-duplicates by open id (LinkEvent.id), so nothing is counted twice. Navigation never waits for a report: onLink fires before it is sent. The first launch of an install is marked as such, so dashboards can tell new users (installed and opened) from existing users (already had the app). The deferred check is only marked done once the server answered, so an offline first launch is retried on the next launch. strait.pendingOpenReports() (count) and strait.flushOpenReports() (send now) are blocking; call them off the main thread.

3. Play Install Referrer (com.android.installreferrer:installreferrer)

installReferrer is called on the executor, so it may block:

fun readInstallReferrer(context: Context): String? {
    val latch = java.util.concurrent.CountDownLatch(1)
    var result: String? = null
    val client = InstallReferrerClient.newBuilder(context).build()
    try {
        client.startConnection(object : InstallReferrerStateListener {
            override fun onInstallReferrerSetupFinished(code: Int) {
                if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
                    result = runCatching { client.installReferrer.installReferrer }.getOrNull()
                }
                latch.countDown()
            }
            override fun onInstallReferrerServiceDisconnected() { latch.countDown() }
        })
        latch.await(5, java.util.concurrent.TimeUnit.SECONDS)
    } catch (_: Exception) {
    } finally {
        runCatching { client.endConnection() }
    }
    return result
}

4. Events and the fingerprint check (call off the main thread)

strait.trackEvent("purchase", value = 49.99, currency = "USD", linkId = lastLinkId, eventId = order.id)
strait.reportFingerprint()    // POST /v1/debug/fingerprint, origin "app"
strait.compareFingerprint()   // GET  /v1/debug/fingerprint → JSONObject
strait.checkDeferred()        // re-run the deferred check (debug; ignores the once-per-install flag)

trackEvent carries the tap id of the last attributed link open for 7 days, so the dashboard can place the revenue on that tap's channel and A/B variant (contracts B15/B16). Every attributed open supplies one: a browser hand-off, a Play install, or the engine's reply to a verified short link or a deferred match. A newer open replaces the older tap. Pass clickId = … to set it yourself.

eventId is your id for the conversion (your order id; 1-64 of A-Z a-z 0-9 _ . : -): Strait stores each one once, so a retry or a second report of the same order never counts twice. Left out, the SDK makes one. An event that can't be sent (offline, server busy) is kept with the saved open reports and sent later, for up to 7 days; trackEvent then returns false (contract B13a). Refunds are not supported yet. Revenue you must trust (billing, payouts) belongs on your server with the secret key: the app's publishable key is public, so anyone who can guess an order id could send it first.

HTTP defaults to UrlConnectionTransport (HttpURLConnection). To use OkHttp, implement HttpTransport: throw on network failure and return every HTTP status as an HttpResponse.

Publishable key: Dashboard → Get started → Publishable key (st_pub_live_…). It's safe to include in your app. Never put your secret key (st_live_…) in an app.

Check your setup (doctor)

Most "the link opens the browser, not my app" problems are setup: the package or this build's signing certificate isn't listed in Dashboard → Apps & stores, or a secret key is in the app. doctor() checks your key, your endpoint and your link host's /.well-known/assetlinks.json against this app (contract B23). It makes one GET, never throws and prints each finding (logcat tag System.out), the same line as every Strait SDK. Call it off the main thread, in debug builds only.

// in a debug build, on a background thread
@Suppress("DEPRECATION")
val signers: Array<android.content.pm.Signature> = if (Build.VERSION.SDK_INT >= 28) {
    val info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
    info.signingInfo?.apkContentsSigners ?: emptyArray()
} else {
    packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES).signatures ?: emptyArray()
}
val sha256 = signers.map { s ->
    MessageDigest.getInstance("SHA-256").digest(s.toByteArray()).joinToString(":") { "%02X".format(it) }
}
val report = strait.doctor(context.packageName, sha256) // report.ok, report.findings

You should see (all set up):

[Strait doctor] ok key.ok: Publishable key is set
[Strait doctor] ok app.listed: <handle>.strait.link lists com.yourcompany.app
[Strait doctor] ok cert.listed: This build's signing certificate is listed

report.ok is false when any finding is an error; each one's fix says what to change, e.g. [Strait doctor] error cert.missing: This build's signing certificate isn't listed for com.yourcompany.app. Fix: Add FA:C6:… in Dashboard → Apps & stores. Debug, release and Play App Signing keys each have their own fingerprint. Intent filters stay with the free web checker and the docs: the SDK can't see them. doctorFindings(...) is the same check as a pure function over a file you fetched yourself.

5. Store sheet (new): install another app without leaving yours

When a user taps Install for one of your other apps (a sibling, partner or "lite" app), open Google Play inside your app and keep the deep link. The installed app opens on the linked screen through its normal deferred check.

// Off the main thread (it calls POST /v1/store-sheet first).
val result = strait.openStoreSheet(
    "https://<handle>.strait.link/promo",
    StoreLauncher { s ->
        val intent = Intent(s.action, Uri.parse(s.data)).apply {
            s.packageName?.let { setPackage(it) }
            for ((k, v) in s.extras) when (v) {
                is Boolean -> putExtra(k, v)
                is String -> putExtra(k, v)
            }
            if (activity == null) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        try { (activity ?: context).startActivity(intent); true }
        catch (_: ActivityNotFoundException) { false }
    },
    StoreSheetOptions(callerId = context.packageName),
)
// result.method: "inline_install" | "market" | "web" | "none"

Order tried: Google Play inline install (a half-sheet over your app; Google labels it a test feature, and Play shows the full listing when the sheet can't show), then the Play app (market://), then the Play web page. Each carries referrer=strait_link=<id>&strait_click=<tap>, which the installed app's Play Install Referrer match reads exactly. The tap is recorded with sent_to = store_sheet and is not billed while the store sheet is new.

It works only where your app is the host. A link tapped inside another company's app can't open a store sheet there. If the engine can't be reached, pass androidPackage to still open the store (the deep link is not kept then, result.reason = "offline").

Old Firebase page.link links (contract B22)

Moving off Firebase Dynamic Links? People who already have your app can keep opening it from old <x>.page.link links, as long as Google keeps serving page.link's assetlinks.json (it still does today; nobody but Google controls page.link):

  1. Keep the page.link intent filter (android:host="<x>.page.link", autoVerify) in your next app build.
  2. Add the host: StraitConfig(…, linkHosts = listOf("<x>.page.link")).
  3. Import your old links in the dashboard (Import → Firebase); each keeps its old host and code.

A page.link short link (https://<x>.page.link/aBcD) is then looked up like any Strait short link; a long link (https://<x>.page.link/?link=https://…) opens its link= destination on the device, with no network call. People without your app still land on whatever Google serves. Covered by unit tests; not yet tested on a real phone.

User consent / opt-out

If your app asks for consent first (for example under India's DPDP Act or a GDPR consent flow in the EU), call strait.setEnabled(false) until the person agrees: Strait then sends no open reports or events, does no install matching, and deletes reports waiting on the device. The choice is kept across launches in your storage; setEnabled(true) turns it back on (default: on) and isEnabled() reads it. Links that carry their own destination still open your screens. A Strait short link is looked up through the engine's /v1/lookup, which records nothing (no tap, no open, no tap id), so it still opens the right screen; only if that lookup fails (offline) does it come back matched = false, reason = "disabled". Whether you need consent, and for what, is a question for your own legal advice.

Lower-level helpers (signature only; the rest internal since 0.8.0)

computeSignature / h32 (the cross-language deferred-match signature). The contract helpers in Core.kt (classifyUrl, pruneOpenQueue, parseStraitLink, ...) are internal since 0.8.0; StraitClient does all of it for you.

Size and speed

Measured 25 Oct 2026 on 0.8.4.

What Size or time How it was measured
Library 95 KB jar. Its only dependency is kotlin-stdlib, which Kotlin apps already ship ./gradlew jar
In your app after R8 about 35 KB of dex (the SDK plus the parts of the Kotlin stdlib it uses). The whole sample APK is 45 KB examples/android, release build with isMinifyEnabled = true, apkanalyzer
Start-up on the main thread 0.43 ms for StraitClient(StraitConfig(...)) + 0.05 ms for start() (network work then runs on your executor) the sample app on an Android 15 arm64 emulator, release build, median of 6 cold launches

Example app

examples/android is a one-screen app: it opens a deep link (straitsample://… or an App Link on your Strait host), shows the deferred link after an install and logs a purchase event. It builds the SDK from this repo's source, and CI builds it on every pull request.

  1. cp examples/android/strait.example.properties examples/android/strait.properties and set your publishable key (st_pub_…, Dashboard → Get started; the file is git-ignored) and STRAIT_ENDPOINT, your link domain (https://<handle>.strait.link); the App Links host in the manifest follows it. For the App Link to open the app, add com.straitlink.example and your debug key's SHA-256 in Dashboard → Apps & stores.
  2. ./gradlew -p examples/android installDebug from the repo root, with a phone or emulator attached (JDK 17+, Android SDK).
  3. adb shell am start -a android.intent.action.VIEW -d "straitsample://product/42" shows the link on screen.

Test

gradle test     # JDK 17; CI runs the same on Temurin 17

Docs and support

About

Strait SDK for Android (Kotlin, via JitPack): App Links, deferred deep links with the Play Install Referrer, and link-open analytics.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages