Android SDK (Kotlin)
The Android SDK supports Android 7 (API 24) and later. Its only third-party runtime dependency is Google's Play Install Referrer library 2.2 (no AndroidX, no OkHttp, no Play Services in the core).
| Artifact | Contains |
|---|---|
io.x3growth:sdk | sessions, screens, events, identify, leads, purchase hints, consent with View dialogs, install attribution, deep links |
io.x3growth:sdk-replay | wireframe recordings for Views and Jetpack Compose |
io.x3growth:sdk-compose | Compose navigation tracking, masking modifiers, Compose consent sheet and settings |
Every method is safe from any thread, never blocks the caller and is processed in call order on one
background thread (x3-sdk). Before configure every call is a no-op with a debug log.
Install
dependencies {
implementation("io.x3growth:sdk:1.0.0")
implementation("io.x3growth:sdk-replay:1.0.0") // optional: recordings
implementation("io.x3growth:sdk-compose:1.0.0") // optional: Compose helpers
}The artifacts are on Maven Central (mavenCentral() in settings.gradle.kts).
Configure
In your Application class:
package app.nordlicht
import android.app.Application
import io.x3growth.sdk.X3
import io.x3growth.sdk.X3ConsentMode
import io.x3growth.sdk.X3ConsentOptions
import io.x3growth.sdk.X3Options
import io.x3growth.sdk.X3Purposes
import io.x3growth.sdk.X3RecordingMode
import io.x3growth.sdk.X3RecordingOptions
class NordlichtApp : Application() {
override fun onCreate() {
super.onCreate()
X3.configure(this, "pk_3x_YOUR_APP_KEY", X3Options(
consent = X3ConsentOptions(mode = X3ConsentMode.OPT_IN, purposes = X3Purposes(recordings = true)),
host = "https://nordlicht.app/n3",
recording = X3RecordingOptions(mode = X3RecordingMode.WIREFRAME),
))
}
}Register the class in the manifest (android:name=".NordlichtApp"). consent.mode is required:
ALWAYS, OPT_IN or OPT_OUT (consent in apps). All options and defaults:
app SDK reference › Options. The SDK keeps its files in
noBackupFilesDir/io.x3growth.sdk/ (never backed up, never shared).
Screens
Activities are tracked when they resume (autoScreens, default on); the simple class name becomes
the path (PaywallActivity → /paywall). Name a screen yourself:
class PlanActivity : AppCompatActivity(), X3ScreenNameProvider {
override val x3ScreenName = "Plan"
}Fragments are not tracked automatically; call X3.screen("Plan") in onResume. With
Jetpack Compose Navigation (sdk-compose), report every destination route:
@Composable
fun NordlichtNavHost() {
val navController = rememberNavController()
X3TrackNavigation(navController) // registers once per controller, removed when the composable leaves
NavHost(navController, startDestination = "home") { /* … */ }
}Inside a composable always use X3TrackNavigation: it registers the listener in a
DisposableEffect. X3Compose.trackNavigation(navController) is the variant for code outside
Compose; it returns an X3Subscription that you cancel when the controller goes away (calling it in
a composable body would add a listener on every recomposition). Route patterns such as plan/{id}
are reported as paths with the placeholder, never the argument values; ids in paths are redacted on
the server.
Events
X3.track("plan_selected", mapOf("plan" to "year", "trial" to true, "price" to 59.99))Same rules as on iOS: lowercase snake_case names, up to 20 properties with string, number or boolean values (reference › track).
Identify
X3.identify(user.id, user.email, null) // after signup, every login and at start when logged in
X3.track("account_created", mapOf("method" to "google"))
X3.track("login", mapOf("method" to "password"))
X3.reset() // at logoutLeads
lifecycleScope.launch {
try {
X3.lead(X3Lead(form = "consultation", email = email, firstName = name, message = text, marketingConsent = newsletter))
} catch (error: X3Error) {
// error.code: X3Error.Code.INVALID, DISABLED, RATE_LIMITED, NETWORK, SERVER, NOT_CONFIGURED
// subclasses: X3Error.RateLimited (retryAfterSeconds), X3Error.Server (status), …
}
}From Java or without coroutines: X3.lead(lead, callback).
Purchases with Play Billing
Pass the account token as obfuscated account id, so Google's notifications name the person, and report the purchase as a hint after Play confirms it:
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.apply { X3.purchaseAccountToken()?.let { setObfuscatedAccountId(it) } }
.build()
billingClient.launchBillingFlow(activity, params)
// in onPurchasesUpdated, for Purchase.PurchaseState.PURCHASED:
X3.purchase(X3Purchase(orderId = purchase.orderId ?: return, value = 59.99, currency = "EUR", productId = "pro_yearly"))The conversion stays held until Google Play (RTDN), RevenueCat or your server confirms the order id
GPA.… (purchases and subscriptions).
Deep links and App Links
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
X3.handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
X3.handleIntent(intent)
}handleIntent reads intent.data, marks the intent as handled so it is not counted twice and
returns true when the link carried X3 attribution. The SDK also checks the launching intent of
each activity once. For X3 links to open the app directly, add an App Links intent filter for your
link path and publish assetlinks.json:
<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="nordlicht.app" android:pathPrefix="/n3/go" />
</intent-filter>[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": { "namespace": "android_app", "package_name": "app.nordlicht", "sha256_cert_fingerprints": ["AB:CD:…"] }
}]Use the SHA-256 fingerprint of your Play app signing key (Play Console › App integrity).
Play Install Referrer
When the purpose attribution is granted, the SDK reads the Play Install Referrer once (within 90
days of the install) on a background thread and sends it with the first open. Any Play link with a
referrer= parameter works: X3 links, newsletters, QR codes, partner pages. The server reads X3
click tokens, UTM parameters, gclid and Meta's encrypted install referrer
(Play Install Referrer).
Advertising id
The SDK never reads the Google advertising id and removes the permission
com.google.android.gms.permission.AD_ID from its manifest (tools:node="remove"). If another SDK
in your app needs the advertising id, check the merged manifest (Android Studio › Merged Manifest)
and declare your own use in the Play Console. In the Play Console declaration "Does your app use
advertising ID?" X3 alone means "No".
Recordings
Add sdk-replay, set recording = X3RecordingOptions(mode = X3RecordingMode.WIREFRAME) and use
the purpose recordings. Views and Compose (through the semantics tree) are captured; WebViews,
SurfaceView, TextureView, maps and password fields are always masked.
X3.setMasking(cardView, X3Masking.MASK) // Views
X3.setMasking(priceView, X3Masking.UNMASK)
X3.setAnchor(buyButton, "buy_button") // tap target name (else the resource entry name)
X3.setAnchorResolver { view -> view.tag as? String }
Text(price, Modifier.x3Unmask()) // Compose
GoogleMap(modifier = Modifier.x3Mask())
Button(onClick = {}, modifier = Modifier.x3Anchor("buy_button")) { Text("Buy") } // or Modifier.testTagConsent UI
// Views (core artifact): plain dialogs, Material look without a Material dependency, dark mode, TalkBack, 48 dp targets
X3ConsentDialog.show(activity, X3ConsentTexts(appName = "Nordlicht", privacyPolicyUrl = "https://nordlicht.app/privacy")) { state -> }
X3PrivacySettingsDialog.show(activity, X3ConsentTexts())
// Compose (sdk-compose)
X3ConsentSheet(texts = X3ConsentTexts(), onDone = { state -> })
X3PrivacySettings(texts = X3ConsentTexts())Listeners (X3.addConsentListener { state -> }) run on the main thread.
Consent UI helpers.