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.
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 Reflectis@JvmStatic, so Java writesReflect.trackEvent("x")— neverReflect.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 asReflectCallback<T>(also afun interface), always delivered on the main looper, so no Java caller is ever pushed throughContinuationorFunction1. Kotlin callers who want coroutines can wrap any callback insuspendCancellableCoroutinethemselves; the SDK deliberately ships nokotlinx-coroutinesdependency, 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.
-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 wiresdk_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.onCreateruns once per OS process. If your app declares anandroid: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 sameSharedPreferences— corrupting the queue and double-counting installs.initializetherefore 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 setsallowSecondaryProcess = trueon the config. - If you pass an Activity instead. Registering lifecycle callbacks from an Activity's
onCreatehas already missed that Activity's ownonActivityCreated, so the launching intent never reaches the core andgetInitialDeepLink()would return null forever. Ifinitialize(context, …)is handed anActivity, 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,setAdvertisingConsentandsetThirdPartySharingeach take an optional completion carrying a success boolean. The core really can answerprivacy_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, ordeleteUserData()when state must actually be destroyed.deleteUserData(callback)resolvestrueonly when the server explicitly accepts the deletion.falsemeans 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, stampedsource = COLD. - Warm delivery —
onNewIntentis 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
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:
| Group | Member | Notes |
|---|---|---|
| Lifecycle | initialize(context, config) | once, from Application.onCreate |
| Lifecycle | sdkVersion · isInitialized · isDebugMode | sync properties |
| Lifecycle | attachActivity(activity) · detachActivity() | escape hatch; automatic by default |
| Events | trackEvent(name) · trackEvent(name, properties) · trackEvent(name, options) | options via ReflectEventOptions.builder() (partner params, revenue, dedup id) |
| Events | ReflectStandardEvents · ReflectEventValidator | 30 wire-name constants + 20 typed helpers; pre-flight validation |
| Revenue | trackRevenue(ReflectRevenueParams) | |
| Revenue | trackPurchase(ReflectPurchaseParams) · trackSubscription(...) | params have Builders |
| Revenue | verifyPurchase(params, cb) · verifyAndTrackPurchase(params[, cb]) | status VERIFIED/NOT_VERIFIED/FAILED/UNKNOWN; tracks regardless of outcome |
| Revenue | trackAdRevenue(ReflectAdRevenueParams) | fires ad_impression; mappers ReflectMax, ReflectAdMob, ReflectLevelPlay |
| Identity | setUserId(userId?) · clearUserId() | null/empty routes to clearUserId |
| Identity | setEmail(email) · clearEmail() | pass the RAW email — hashed server-side; wrapper-local, never persisted |
| Identity | setUserProperties(map) · setExternalDeviceId(id?) | id = null clears |
| Identity | setGlobalProperty(key, value?) · unsetGlobalProperty · clearGlobalProperties | merged into every event by the core |
| Identity | setPartnerParameter(key, value?) · unsetPartnerParameter · clearPartnerParameters | forwarded to integrations |
| Privacy | setConsent(granted[, cb]) · getConsent() | callback carries persistence success |
| Privacy | setAdvertisingConsent(granted[, cb]) | GAID consent |
| Privacy | setThirdPartySharing(enabled[, cb]) · setPartnerSharing(partner, key, value) | |
| Privacy | setEnabled(enabled[, cb]) · isEnabled(cb) · setOfflineMode(offline) | offline keeps tracking + queuing on-device |
| Privacy | deleteUserData([cb]) · setIntegrityToken(token?) | GDPR delete; Play Integrity token |
| Deep links | addDeepLinkListener(l): ReflectListenerToken · removeDeepLinkListener(l) | replays the last link on subscribe |
| Deep links | lastDeepLink · getLastDeepLink(cb) · getInitialDeepLink(cb) | |
| Deep links | handleIntent([activity,] intent) · handleDeepLink(url) · resolveDeepLink(url, cb) | resolveDeepLink unshortens a branded tracking URL server-side |
| Attribution | addAttributionListener(l): ReflectListenerToken · removeAttributionListener(l) | the only path that carries clickId |
| Attribution | getAttribution(cb) · getAttributionWithTimeout([timeoutMs,] cb) | milliseconds, default 3000, clamped to 1 hour |
| Push & audience | setPushToken(token) · registerPushToken(token[, provider]) | the second fires _push_token |
| Push & audience | setAudience(vararg tags) · setAudience(List<String>) | |
| Diagnostics | flush() · getDebugState(cb) | snapshot is PII-safe — identifiers appear only as presence booleans |
| Diagnostics | ReflectDebugOverlay.attach(activity) · ReflectDebugActivity.start(context) | opt-in, never self-installing; gated on your app's debuggable flag |
| iOS parity | requestTrackingAuthorization(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
| Symptom | Cause / fix |
|---|---|
Could not find com.github.bablu147:reflect-android:1.1.2 | The 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.ReflectCore | Your 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 ingest | signingSecret missing for an app on the shared_hmac policy. |
| Events accepted locally, nothing on the server | baseUrl 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 appears | Client-side validation dropped it. Set debug = true — the reason is logged there and nowhere else. |
| Nothing at all is collected | The privacy gate is closed: not initialized, requireConsent with no grant yet, setEnabled(false), or a denial. Check getDebugState → trackingEnabled / consentState. |
getInitialDeepLink() always null | initialize 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 builds | R8 stripped AdvertisingIdClient — check that consumer-rules.pro is applied and no rule of yours strips it back out. |
| Installs double-counted, queue corrupted | The SDK is running in more than one process. Do not set allowSecondaryProcess = true unless you mean it. |
| Debug overlay never appears | The host app is not debuggable. The gate is ApplicationInfo.FLAG_DEBUGGABLE, not the library's BuildConfig.DEBUG. |
| Install state survives an uninstall while testing | Android Auto Backup restores install_uuid. Run adb shell bmgr enable false, then uninstall and reinstall, then re-enable it. |
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.