Browse docsOverview
Android SDK

Android SDK

Reflect SDK for native Android — a Kotlin facade over the shared ReflectCore engine, fully consumable from Java. Attribution, event tracking, deep linking, revenue measurement and a fail-closed privacy gate for apps built directly on the Android SDK, not through Flutter, React Native or Unity.

One artifact serves Kotlin and Java — there is no separate Java SDK

com.github.bablu147:reflect-sdk-android is written in Kotlin and is designed to be called from Java without a shim, a wrapper module, or kotlin-stdlib on your compile classpath. That is a deliberate design constraint on the public surface, not a happy accident:

  • Every member of object Reflect is @JvmStatic, so Java writes Reflect.trackEvent("x") — never Reflect.INSTANCE.trackEvent("x").
  • Config and parameter objects expose Builders (ReflectConfig.builder(...), ReflectPurchaseParams.builder(...), ReflectEventOptions.builder()), because Kotlin default arguments are invisible to Java — without a Builder a Java caller would have to pass every parameter positionally.
  • Every listener is a fun interface, so Java 8 lambdas work: Reflect.addDeepLinkListener(link -> router.open(link.url)).
  • Nothing on the public surface is suspend. Async results come back as ReflectCallback<T> (also a fun interface), always delivered on the main looper, so no Java caller is ever pushed through Continuation or Function1. Kotlin callers who want coroutines can wrap any callback in suspendCancellableCoroutine themselves; the SDK deliberately ships no kotlinx-coroutines dependency, so it never lands on your classpath.

A pure-Java sample app is built in CI on every change specifically to keep it that way — if a change made the Java sample need kotlin-stdlib to compile, that is treated as an API regression.

Installation

Integration is two Gradle edits. First add JitPack to your app's repositories. For a native Android app that means the dependencyResolutionManagement block in settings.gradle:

// settings.gradle
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://jitpack.io' }   // com.github.bablu147
    }
}

Then add the dependency:

// app/build.gradle
dependencies {
    implementation 'com.github.bablu147:reflect-sdk-android:1.0.1'
}

Kotlin DSL:

// app/build.gradle.kts
dependencies {
    implementation("com.github.bablu147:reflect-sdk-android:1.0.1")
}

Nothing else. The AAR pulls the shared core (com.github.bablu147:reflect-android:1.1.2) and its own transitive dependencies (Play Install Referrer, Play Services ads-identifier and app-set, OkHttp), merges its manifest — permissions plus the Android 11 <queries> block the install-referrer providers need — and ships a consumer-rules.pro, so there is no manual ProGuard/R8 setup.

Do not add your own keep rule
Unlike the Unity plugin, which is reached by name over JNI, this facade and the core are referenced statically from your own code, so R8 keeps exactly what you use. Adding -keep class com.reflect.android.** { *; } only defeats shrinking. The shipped consumer rules already keep the reflectively-loaded pieces R8 cannot see (AdvertisingIdClient, App Set ID, install referrer, OAID, Huawei referrer).

Requirements

  • minSdk 21, compileSdk 34, Java 8 bytecode
  • Kotlin 1.8.22 (not required in your app — the artifact is Java-consumable)
  • Package com.reflect.android; brand on the wire sdk_version = android-1.0.1
  • No direct AndroidX dependencies, no Compose, no third-party UI dependency — Google Play services does pull a small AndroidX set into the resolved graph transitively
  • Production endpoint https://api.reflect.cloud — the SDK defaults to it

Quick start

Call initialize once, from Application.onCreate. An app_open event is sent automatically.

Kotlin — named arguments, defaults for anything you skip:

package com.example.app

import android.app.Application
import com.reflect.android.Reflect
import com.reflect.android.ReflectConfig

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        val config = ReflectConfig(
            appKey        = BuildConfig.REFLECT_APP_KEY,
            signingSecret = BuildConfig.REFLECT_SIGNING_SECRET,
            // companyKey  = "acme",                     // multi-tenant setups
            // baseUrl     = "https://api.reflect.cloud", // override the endpoint
            debug          = BuildConfig.DEBUG,
            requireConsent = true,                       // start fail-closed under GDPR
        )
        Reflect.initialize(this, config)
    }
}
Reflect.trackEvent("level_complete", mapOf("level" to 7, "score" to 42_000))
Reflect.setUserId("user-123")
Reflect.addDeepLinkListener { link -> router.open(link.url) }

Java — identical surface, the config built through its Builder:

package com.example.app;

import android.app.Application;
import com.reflect.android.Reflect;
import com.reflect.android.ReflectConfig;

public class MyApp extends Application {
    @Override public void onCreate() {
        super.onCreate();

        ReflectConfig config = ReflectConfig.builder(BuildConfig.REFLECT_APP_KEY)
                .signingSecret(BuildConfig.REFLECT_SIGNING_SECRET)
                // .companyKey("acme")
                // .baseUrl("https://api.reflect.cloud")
                .debug(BuildConfig.DEBUG)
                .requireConsent(true)
                .build();
        Reflect.initialize(this, config);
    }
}
Map<String, Object> props = new HashMap<>();
props.put("level", 7);
props.put("score", 42000);
Reflect.trackEvent("level_complete", props);
Reflect.setUserId("user-123");
Reflect.addDeepLinkListener(link -> router.open(link.url));

New mobile apps default to the server policy shared_hmac: for those apps signingSecret is required, and an unsigned fallback is rejected with signature_required. Inject credentials through ignored local/CI configuration — never commit a real value.

Where initialize must go

Application.onCreate is the only supported location, and two distinct behaviours exist because the alternatives are common mistakes:

  • Multi-process guard. Application.onCreate runs once per OS process. If your app declares an android:process=":push" service, a separate WebView or media process, or a remote WorkManager process, each of those would otherwise start a second core writing the same durable queue and the same SharedPreferences — corrupting the queue and double-counting installs. initialize therefore short-circuits (logging once) in any process whose name is not the package name. A host that genuinely wants the SDK in a secondary process sets allowSecondaryProcess = true on the config.
  • If you pass an Activity instead. Registering lifecycle callbacks from an Activity's onCreate has already missed that Activity's own onActivityCreated, so the launching intent never reaches the core and getInitialDeepLink() would return null forever. If initialize(context, …) is handed an Activity, the SDK attaches it and forwards its intent immediately as a rescue, and logs a warning telling you to move the call.

Privacy & consent

The privacy gate starts closed and opens only once the core reports its persisted posture. Any sync failure leaves it closed — nothing is collected until consent is known to be durable. While the gate is closed, measurement calls are buffered in a bounded, generation-fenced FIFO; while consent is denied, they are dropped.

Reflect.setConsent(false)                 // consent_state: "denied"
Reflect.setEmail("[email protected]")      // ignored while denied
Reflect.trackEvent("checkout")            // dropped while denied

Reflect.setConsent(true) { ok ->          // waits for native confirmation
    if (!ok) Log.w(TAG, "consent did not durably persist")
}
  • setConsent, setEnabled, setAdvertisingConsent and setThirdPartySharing each take an optional completion carrying a success boolean. The core really can answer privacy_persistence_failed, and a GDPR opt-out that failed to persist is something a host must be able to learn about.
  • Denial erases wrapper-held state immediately — the sticky email, the local property mirror, cached identifiers, the recent-events ring — and fails every in-flight privacy-sensitive callback. A later grant starts clean, so you must re-supply identity fields.
  • setEnabled(false) is reversible. It blocks collection and PII setters but keeps wrapper state behind the closed gate for a native-confirmed re-enable. Use the explicit clear APIs, consent denial, or deleteUserData() when state must actually be destroyed.
  • deleteUserData(callback) resolves true only when the server explicitly accepts the deletion. false means remote acknowledgement was not confirmed — local cleanup and suppression are already done, and the delete stays queued for retry.

Apps aimed at children should set coppaCompliant = true and strip the advertising-ID permission the AAR merges in:

<manifest xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="com.google.android.gms.permission.AD_ID"
        tools:node="remove" />
</manifest>

Deep links

Declare your intent filters in AndroidManifest.xml exactly as usual. Then most of it is automatic:

  • Cold launch — the launching Activity's intent is forwarded on onActivityCreated, stamped source = COLD.
  • Warm deliveryonNewIntent is captured through the same lifecycle hook.
  • Deferred — resolved by the core after install attribution, delivered with isDeferred = true, source = DEFERRED.

The one case a host must forward something. If you consume the URL yourself — a custom router, an in-app WebView, or another SDK that swallows the intent — hand it over from onNewIntent:

override fun onNewIntent(intent: Intent?) {
    super.onNewIntent(intent)
    Reflect.handleIntent(this, intent)     // also does setIntent(intent)
}

// or, if you only have the URL:
Reflect.handleDeepLink("https://link.example.com/promo?click_id=...")

Both paths are gated identically — the host-called form gets no privilege over the OS-delivered one. Subscribing replays the last link to a late subscriber, so a listener registered after the cold-launch link still sees it. The replayed copy is query- and fragment-stripped and carries no clickId: click context is callback-scoped and single-use, so read it inside the callback or lose it.

val token = Reflect.addDeepLinkListener { link ->
    // link.url, link.path, link.params, link.campaign, link.partner, link.source
}
token.cancel()

ATT parity on Android

UNAVAILABLE and GATE_CLOSED mean different things

requestTrackingAuthorization(callback) exists on Android so one cross-platform call site compiles unchanged on both platforms. There is no ATT prompt on Android, so it always answers ReflectTrackingStatus.UNAVAILABLE (code 99) — the same value iOS reports on iOS < 14 or when AppTrackingTransparency is missing.

A closed privacy gate returns the distinct GATE_CLOSED (code 98) instead, on both platforms. That is not the same claim: UNAVAILABLE says this platform cannot present a prompt, while GATE_CLOSED says the call never reached the core because consent was not granted — grant it and call again. Conflating the two once made a shut gate look like an OS limitation on a modern iPhone, which is undebuggable; treat them separately in your call site.

updateConversionValue(...) is likewise present for parity — SKAdNetwork is an iOS concept, so the Android core is a stub that answers success. The 0..63 fine-value guard is enforced client-side either way.

API surface

Every member is @JvmStatic on object Reflect; async results are ReflectCallback<T> delivered on the main looper. The most-used members, grouped:

GroupMemberNotes
Lifecycleinitialize(context, config)once, from Application.onCreate
LifecyclesdkVersion · isInitialized · isDebugModesync properties
LifecycleattachActivity(activity) · detachActivity()escape hatch; automatic by default
EventstrackEvent(name) · trackEvent(name, properties) · trackEvent(name, options)options via ReflectEventOptions.builder() (partner params, revenue, dedup id)
EventsReflectStandardEvents · ReflectEventValidator30 wire-name constants + 20 typed helpers; pre-flight validation
RevenuetrackRevenue(ReflectRevenueParams)
RevenuetrackPurchase(ReflectPurchaseParams) · trackSubscription(...)params have Builders
RevenueverifyPurchase(params, cb) · verifyAndTrackPurchase(params[, cb])status VERIFIED/NOT_VERIFIED/FAILED/UNKNOWN; tracks regardless of outcome
RevenuetrackAdRevenue(ReflectAdRevenueParams)fires ad_impression; mappers ReflectMax, ReflectAdMob, ReflectLevelPlay
IdentitysetUserId(userId?) · clearUserId()null/empty routes to clearUserId
IdentitysetEmail(email) · clearEmail()pass the RAW email — hashed server-side; wrapper-local, never persisted
IdentitysetUserProperties(map) · setExternalDeviceId(id?)id = null clears
IdentitysetGlobalProperty(key, value?) · unsetGlobalProperty · clearGlobalPropertiesmerged into every event by the core
IdentitysetPartnerParameter(key, value?) · unsetPartnerParameter · clearPartnerParametersforwarded to integrations
PrivacysetConsent(granted[, cb]) · getConsent()callback carries persistence success
PrivacysetAdvertisingConsent(granted[, cb])GAID consent
PrivacysetThirdPartySharing(enabled[, cb]) · setPartnerSharing(partner, key, value)
PrivacysetEnabled(enabled[, cb]) · isEnabled(cb) · setOfflineMode(offline)offline keeps tracking + queuing on-device
PrivacydeleteUserData([cb]) · setIntegrityToken(token?)GDPR delete; Play Integrity token
Deep linksaddDeepLinkListener(l): ReflectListenerToken · removeDeepLinkListener(l)replays the last link on subscribe
Deep linkslastDeepLink · getLastDeepLink(cb) · getInitialDeepLink(cb)
Deep linkshandleIntent([activity,] intent) · handleDeepLink(url) · resolveDeepLink(url, cb)resolveDeepLink unshortens a branded tracking URL server-side
AttributionaddAttributionListener(l): ReflectListenerToken · removeAttributionListener(l)the only path that carries clickId
AttributiongetAttribution(cb) · getAttributionWithTimeout([timeoutMs,] cb)milliseconds, default 3000, clamped to 1 hour
Push & audiencesetPushToken(token) · registerPushToken(token[, provider])the second fires _push_token
Push & audiencesetAudience(vararg tags) · setAudience(List<String>)
Diagnosticsflush() · getDebugState(cb)snapshot is PII-safe — identifiers appear only as presence booleans
DiagnosticsReflectDebugOverlay.attach(activity) · ReflectDebugActivity.start(context)opt-in, never self-installing; gated on your app's debuggable flag
iOS parityrequestTrackingAuthorization(cb) · updateConversionValue(...)see the ATT parity note above

Event validation

Every event is validated client-side before dispatch: name 1–64 chars matching ^[a-z][a-z0-9_-]*$, at most 25 properties, keys at most 40 chars, string values truncated at 1024. An invalid event is dropped silently and only logged — so it is invisible unless debug = true. Pre-flight it yourself if you generate names dynamically:

val result = ReflectEventValidator.validate(name, props)
if (!result.isValid) Log.w(TAG, result.error!!)

Troubleshooting

SymptomCause / fix
Could not find com.github.bablu147:reflect-android:1.1.2The JitPack repository is missing. The SDK cannot inject a repository into your build — Gradle resolves repositories only from your own settings.gradle dependencyResolutionManagement block (or an allprojects block on older setups). Add maven { url 'https://jitpack.io' } and re-sync.
Duplicate class com.reflect.core.ReflectCoreYour app resolves both this SDK and the core AAR directly, or an SDK build compiled with local core sources was published. Depend on reflect-sdk-android only.
signature_required from ingestsigningSecret missing for an app on the shared_hmac policy.
Events accepted locally, nothing on the serverbaseUrl is null/empty, which puts the core in debug mode: it collects, queues and serialises but opens no socket. The debug overlay shows a red banner.
An event never appearsClient-side validation dropped it. Set debug = true — the reason is logged there and nowhere else.
Nothing at all is collectedThe privacy gate is closed: not initialized, requireConsent with no grant yet, setEnabled(false), or a denial. Check getDebugStatetrackingEnabled / consentState.
getInitialDeepLink() always nullinitialize was called from an Activity's onCreate rather than Application.onCreate; the launch intent was already missed. A warning is logged.
GAID null only in release buildsR8 stripped AdvertisingIdClient — check that consumer-rules.pro is applied and no rule of yours strips it back out.
Installs double-counted, queue corruptedThe SDK is running in more than one process. Do not set allowSecondaryProcess = true unless you mean it.
Debug overlay never appearsThe host app is not debuggable. The gate is ApplicationInfo.FLAG_DEBUGGABLE, not the library's BuildConfig.DEBUG.
Install state survives an uninstall while testingAndroid Auto Backup restores install_uuid. Run adb shell bmgr enable false, then uninstall and reinstall, then re-enable it.
Which Reflect SDK am I reading about?
This page covers the native Android SDK (com.github.bablu147:reflect-sdk-android:1.0.1, wire brand android-1.0.1) for apps written directly against the Android framework in Kotlin or Java. If your app is built with Flutter, React Native or Unity, use the wrapper for that toolchain instead — they run on the same shared ReflectCore engine and report their own brand (flutter-1.7.3, react-native-2.0.4, unity-2.4.3). Do not add this artifact alongside one of them.