Native Android SDK for LinkForty — the open-source alternative to Branch.io, AppsFlyer OneLink, and Firebase Dynamic Links. Add deferred deep linking, mobile attribution, and smart link routing to your Android app. Self-hosted, privacy-first, no per-click pricing. 100% Kotlin with modern coroutines APIs.
- Deferred Deep Linking: Match app installs to link clicks: exactly, from the Google Play install referrer (on a server that supports it), and otherwise from device signals (fingerprinting)
- App Links: Full support for Android App Links (verified HTTPS deep links)
- Custom URL Schemes: Handle custom app URL schemes
- Event Tracking: Track in-app events and conversions
- Revenue Tracking: Dedicated revenue tracking with
BigDecimalamounts - Last-Click Attribution: In-app events are automatically credited to the deep link that most recently opened the app
- Screen-Flow Tracking: Report screen views (manually or automatically via a Jetpack Navigation listener) to see what users do after clicking a link
- Offline Support: Events are stored on the device until the server has them (max 100), and sent on the next launch, when the app returns to the foreground, or after the next event that sends
- Programmatic Link Creation: Create short links directly from your app
- Privacy-First: Never reads the Google Advertising ID (GAID)
- Kotlin-Native: 100% Kotlin, modern coroutines API
- Android API 26+ (Android 8.0 Oreo)
- Kotlin 1.9+
- JDK 17
The SDK is published to Maven Central. Make sure mavenCentral() is in your repositories (new Android Studio projects have it in settings.gradle.kts):
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}dependencies {
implementation("com.linkforty:sdk:1.8.0")
}dependencies {
implementation 'com.linkforty:sdk:1.8.0'
}In your Application class (register it with android:name on <application> in your manifest). initialize() is a suspend function, so call it from a coroutine:
import android.app.Application
import android.util.Log
import com.linkforty.sdk.LinkForty
import com.linkforty.sdk.models.LinkFortyConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MyApplication : Application() {
// Any dispatcher works: callbacks registered and links handled before
// initialize() finishes are kept until it does.
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onCreate() {
super.onCreate()
appScope.launch {
try {
val config = LinkFortyConfig(
baseURL = "https://api.linkforty.com", // the hosted service; your own server's URL if self-hosted
appToken = "at_a1b2c3d4...", // required on the hosted service
debug = true,
attributionWindowHours = 168 // 7 days (the default)
)
val response = LinkForty.initialize(this@MyApplication, config)
Log.d("LinkForty", "Install ID: ${response.installId}")
} catch (e: Exception) {
Log.e("LinkForty", "Initialization failed", e)
}
}
}
}| Field | Required | Description |
|---|---|---|
baseURL |
Yes | The LinkForty server the SDK talks to: https://api.linkforty.com on the hosted service, or your own server. Must be HTTPS (HTTP is allowed for localhost, 127.0.0.1, 0.0.0.0 and 10.0.2.2). |
appToken |
On the hosted service | Your workspace's public app token (at_…). It matches installs only to your workspace's links, puts organic installs in your workspace, and lets the SDK learn your link domains. Safe to ship in the app. A self-hosted Core server doesn't need it. |
apiKey |
Only for createLink() |
An API key. It is a secret: leave it out unless the app creates links. |
debug |
No | Logs SDK activity to Logcat (default false). |
attributionWindowHours |
No | 1 to 2160 hours (default 168, 7 days). |
If initialize() can't report the install (for example, offline on the first launch), it throws and leaves the SDK uninitialized, keeping registered callbacks, so you can call it again.
Register before or after initialize(). The callback runs once, on the launch that reports the install.
LinkForty.shared.onDeferredDeepLink { deepLinkData ->
if (deepLinkData != null) {
// User installed from a link - navigate to content
Log.d("LinkForty", "Install attributed to: ${deepLinkData.shortCode}")
Log.d("LinkForty", "UTM Source: ${deepLinkData.utmParameters?.source}")
// Navigate to the right content
deepLinkData.customParameters?.get("productId")?.let { productId ->
navigateToProduct(productId)
}
} else {
// Organic install - no attribution
Log.d("LinkForty", "Organic install")
}
}The deferred deep link also carries how certain the match is: confidenceScore, matchedFactors, attributionMethod and competingLinks. competingLinks counts the other links that had a click from the install's network that also reached the confidence threshold; above 0, a tap on another device on the same network may be what led to this install. An app acting on a sensitive link, such as a sign-in or a gift, can treat that match as unverified:
LinkForty.shared.onDeferredDeepLink { deepLinkData ->
if (deepLinkData != null && (deepLinkData.competingLinks ?: 0) > 0) {
askUserToConfirm(deepLinkData) // e.g. "Did you open a gift link?"
}
}First, configure App Links in your AndroidManifest.xml:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<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="go.yourdomain.com" />
</intent-filter>
</activity>Use the host your links are served from (add a <data> element for each custom domain). android:exported="true" is required for an activity with an intent filter when your app targets Android 12 (API 31) or higher. With singleTask (or singleTop), a link tapped while the app is running is delivered to the existing activity's onNewIntent(), so pass the URI from both onCreate() and onNewIntent():
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
intent.data?.let { uri ->
LinkForty.shared.handleDeepLink(uri)
}
}
}
// Register the callback, before or after initialize()
LinkForty.shared.onDeepLink { uri, deepLinkData ->
Log.d("LinkForty", "Deep link opened: $uri")
deepLinkData?.let { data ->
Log.d("LinkForty", "Link data: $data")
data.deepLinkPath?.let { path ->
navigateToPath(path)
}
}
}A link handed to handleDeepLink before initialize() has finished, as when it launches the app, is held and delivered once it has.
Pass every URI that opens the app. handleDeepLink returns true for a LinkForty link, which it looks up on the server, and false for anything else, which it doesn't: a custom-scheme URI, or, once the SDK knows your workspace's link domains (it learns them with an appToken), a web URI on a host that is neither one of them nor the baseURL host. Until it knows them, or without an appToken, every web URI is looked up. onDeepLink receives both, the second with the URI's own parameters.
Server-side resolution: When the SDK is initialized, LinkForty links are resolved via the server to provide enriched data including
deepLinkPath,appScheme, andlinkId. If the server is unreachable, the SDK falls back to local URL parsing, as it does for a URI that isn't a LinkForty link.
The tracking calls are suspend functions: call them from a coroutine, such as lifecycleScope.launch { ... }. They throw LinkFortyError.NotInitialized until initialize() has finished.
// Track a simple event
LinkForty.shared.trackEvent("button_clicked")
// Track event with properties
LinkForty.shared.trackEvent(
name = "purchase",
properties = mapOf(
"product_id" to "123",
"amount" to 29.99,
"currency" to "USD"
)
)
// Track revenue
LinkForty.shared.trackRevenue(
amount = BigDecimal("29.99"),
currency = "USD",
properties = mapOf("product_id" to "123")
)Every event is automatically stamped with the deep link that most recently opened the app (last-click attribution), so the dashboard can show what users do after clicking a link. Events with no preceding deep-link open are reported as organic. No extra code is required.
Reporting screen views lets the dashboard build a per-link screen-flow funnel. Each screen_view carries the same last-click attribution stamp as other events.
Automatic (Jetpack Navigation) — attach LinkFortyNavObserver to your NavController. Each destination's route (Compose) or label (XML nav graph) is reported as it appears:
import com.linkforty.sdk.navigation.LinkFortyNavObserver
navController.addOnDestinationChangedListener(LinkFortyNavObserver())
LinkFortyNavObserverrequiresandroidx.navigation, which your app already has if it uses Jetpack Navigation. The SDK depends on it only ascompileOnly, so apps that don't use Navigation aren't affected.
Manual — call it yourself (e.g. for screens not driven by a NavController):
LinkForty.shared.trackScreenView("ProductDetail")val result = LinkForty.shared.createLink(
CreateLinkOptions(
deepLinkParameters = mapOf("route" to "VIDEO_VIEWER", "id" to "vid123"),
title = "Check this out!",
utmParameters = UTMParameters(source = "app", campaign = "share")
)
)
Log.d("LinkForty", "Share this link: ${result.url}")
// e.g., "https://go.yourdomain.com/tmpl/abc123"Note:
createLinkis asuspendfunction and requires anapiKeyinLinkFortyConfig(it throwsLinkFortyError.MissingApiKeywithout one). It posts toPOST /api/sdk/v1/links, which the hosted service provides. On a server without that endpoint it falls back toPOST /api/links; LinkForty Core's/api/linksrequires anoriginalUrl, whichCreateLinkOptionsdoesn't send, so against a self-hosted Core server create links from your backend instead. See API Reference for all options.
Point baseURL at your Core server. A stock Core server needs no appToken, and no apiKey unless you use createLink (see the note above):
val config = LinkFortyConfig(
baseURL = "https://links.yourcompany.com"
)
LinkForty.initialize(context, config) // from a coroutineval config = LinkFortyConfig(
baseURL = "https://go.yourdomain.com",
attributionWindowHours = 24 // 1 day instead of default 7 days
)LinkForty.shared.getInstallData()?.let { data ->
Log.d("LinkForty", "Short code: ${data.shortCode}")
Log.d("LinkForty", "UTM source: ${data.utmParameters?.source}")
}
LinkForty.shared.getInstallId()?.let { id ->
Log.d("LinkForty", "Install ID: $id")
}trackEvent stores each event on the device and returns once it is sent or queued; an event that can't be sent now is sent later without your app doing anything. These calls are for inspecting or controlling the queue.
// Check queued events count
val count = LinkForty.shared.queuedEventCount
// Manually flush event queue
LinkForty.shared.flushEvents()
// Clear event queue
LinkForty.shared.clearEventQueue()LinkForty.shared.clearData()
// Reset SDK to uninitialized state
LinkForty.shared.reset()Android verifies App Links against a Digital Asset Links file on each link host:
https://go.yourdomain.com/.well-known/assetlinks.json
LinkForty serves this file for you: on the hosted service, once your Android package name and SHA-256 certificate fingerprints are entered in your app configuration; on LinkForty Core, from the ANDROID_PACKAGE_NAME and ANDROID_SHA256_FINGERPRINTS (comma-separated) environment variables. Until they are set, the file returns a 404 and the links open in the browser.
Which fingerprints to list:
- Google Play builds: if Play App Signing is on (the default for new apps), Google re-signs your app, so use the app signing key SHA-256 from Play Console (App integrity > App signing), not your upload key.
- Local and sideloaded builds: the certificate you sign them with, for example the debug keystore:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android.
The file it serves looks like this:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
}
}]Add an intent filter to your main activity (see Quick Start section).
# Check that the file is served
curl https://go.yourdomain.com/.well-known/assetlinks.json
# Android 12+: re-run verification and show the result for each host ("verified")
adb shell pm verify-app-links --re-verify com.yourcompany.yourapp
adb shell pm get-app-links com.yourcompany.yourapp
# Open a link in the app
adb shell am start -a android.intent.action.VIEW \
-d "https://go.yourdomain.com/abc123" \
com.yourcompany.yourapp- No advertising ID: The SDK never reads the Google Advertising ID (GAID).
initialize()takes an optionaldeviceId, sent only if your app passes one. - How installs are matched: For an install from Google Play, by the Play install referrer, which names the link click exactly; otherwise from device signals (fingerprinting), which is probabilistic.
- Install ID: The server assigns an install ID on the first launch. The SDK stores it on the device (in
SharedPreferences, with the install's attribution, the last deep link that opened the app, and any queued events) and sends it with every event, untilclearData()or the app is uninstalled. - User Control:
clearData()deletes the install ID, the install's attribution, the last deep link and queued events from the device; the next launch reports a new install.
- Install report (first launch): time zone, language, screen resolution (pixels), platform and Android version, app version, a User-Agent built from the app name, app version and Android version, the Play install referrer (Google Play installs), the
appTokenif set, the attribution window, the SDK name and version, anddeviceIdif your app passes one. The server also sees the request's IP address, which fingerprint matching uses. - Deep link lookup: the link's path, time zone, language, screen resolution, platform and Android version.
- Events: the install ID, event name and properties, a timestamp, the link (and click) the event is credited to, a session ID, an event ID used to store a retried event once, and the SDK name and version.
The SDK's manifest adds android.permission.INTERNET, and the Play Install Referrer library it depends on adds com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE. Both are normal permissions: Android grants them without asking the user.
The SDK enforces HTTPS for all API endpoints (except localhost, 127.0.0.1, 0.0.0.0 and 10.0.2.2, for development).
./gradlew sdk:testDebugUnitTest./gradlew sdk:lintDebug./gradlew sdk:assembleReleaseThis SDK requires a running LinkForty backend:
- LinkForty Core (open source): Self-host for free
- The hosted LinkForty service (linkforty.com): managed, nothing to run
See: https://github.com/LinkForty/core
- Documentation: docs.linkforty.com
- Issues: GitHub Issues
See CHANGELOG.md for version history.
LinkForty Android SDK is available under the MIT license. See LICENSE for more info.
| Platform | Package |
|---|---|
| React Native | @linkforty/mobile-sdk-react-native |
| Expo | @linkforty/mobile-sdk-expo |
| iOS (Swift) | LinkFortySDK |
| Flutter | linkforty_flutter |
- LinkForty Core — open-source self-hosted deep linking engine
- LinkForty — the hosted service, with a dashboard