πŸ€–

Android SDK

v1.0.0-beta3

Kotlin & Java Β· native Android library

A native Kotlin/Java library for Android apps β€” not a WebView wrapper. It writes into the exact same ConsentRecord / ConsentNotice tables the Web SDK and Webhooks already use, so a consent captured on Android shows up in your dashboard, exports, and re-consent campaigns automatically, with nothing to reconfigure.

24
Android 7.0+ (minSdk)
Compose
native UI, no WebView tax
Offline
gate flips before sync

Published on JitPack

The SDK is available at com.github.DPDPA-Shield.android-sdk:android:v1.0.0-beta3. Add JitPack to your repositories and the dependency to your app. See below.


Testing the SDK - against production or locally

As of 2026-09-03, the MOBILE_APP consent channel and SdkAppIdentity model this SDK relies on are live in production, applied and verified against the real database before that code shipped. You can point a host app straight at https://api.dpdpashield.in with a real tenant API key - this is the natural choice if the person integrating the SDK isn't on your machine or network. Three things are true at once, and separating them is what makes this straightforward:

Do I need an Android app project to test this in?

Yes. The SDK is a library - it has no UI of its own to launch. You need a host app: either a fresh throwaway app created in Android Studio (File β†’ New Project β†’ Empty Activity takes under a minute) or your existing app codebase. Either works identically from the SDK's point of view.

Do I need to publish the SDK to a server first?

No. Publishing to Maven Central / a private registry is only needed for a real release to other developers. For local testing, the SDK is published to your own machine's local Maven cache (~/.m2/repository) with one Gradle command - see Installation below. Your host app then depends on it exactly like a real published library, with no network involved for the SDK artifact itself.

Can I use an emulator, or do I need a physical device?

An emulator is enough for the full flow: fetching the notice, rendering the Compose consent screen, recording a decision, and confirming the ConsentRecord landed in the database. Nothing in this SDK depends on real-device-only hardware. Use a physical device only if you specifically want to test against a release-signed build's certificate fingerprint.

What backend does the app talk to?

Either works. Point it at production (https://api.dpdpashield.in) with a real tenant API key - the simplest option, and the only practical one if whoever is integrating the SDK isn't on your machine or network. Or point it at your local dev API (the same one this repo runs on localhost:3001) if you're actively editing backend routes alongside the app. From an Android emulator, your host machine's localhost is reachable at the special alias 10.0.2.2 instead - e.g. http://10.0.2.2:3001. From a physical device on the same Wi-Fi, use your machine's LAN IP instead (or a tunnel like ngrok/Cloudflare Tunnel if it's not on the same network).

Can I point it at api.dpdpashield.in directly?

Yes. The backend migration this SDK depends on has shipped to production and was verified there (columns, indexes, both foreign keys, and the enum value all confirmed against the live database) before the app-identity code went out. Use a real dpdpa_live_ API key from Settings β†’ API Keys on the tenant you want consent records to land in.

Do I need to register my test app's package name and signing certificate first?

No, not for a first test. Same convention as the Web SDK's domain allowlist: an API key with no registered app identities is treated as unenforced, so any request passes through. Register your app once you're ready to test the enforcement path itself (a request from an unregistered app being rejected) - see App Identity below.

Minimum path to a working test - against production

  1. Log in to dpdpashield.in, copy a dpdpa_live_ API key with a published notice attached.
  2. Publish this SDK to your local Maven cache (Installation, below) - a one-time build step, no ongoing network dependency on this repo.
  3. Create or open a host Android app, add the dependency, point it at https://api.dpdpashield.in.
  4. Run it - emulator or physical device - call loadNotice(), render the screen, tap Accept.
  5. Confirm the ConsentRecord in the dashboard - it should show channel MOBILE_APP.

Prefer a local dev loop instead?

Only step 3 changes: run this repo's API locally (docker-compose up, then the API dev server on port 3001) and point the host app at http://10.0.2.2:3001 from an emulator instead. Useful if you're editing backend routes alongside the SDK and want to see changes without redeploying.


Installation

The SDK is published on JitPack. Two lines in your Gradle config and you're set.

settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://jitpack.io")
    }
}
app/build.gradle.kts
dependencies {
    implementation("com.github.DPDPA-Shield.android-sdk:android:v1.0.0-beta3")
}

The :core module is pulled in transitively. Sync your project and you're ready to go.


Quick Start β€” Kotlin

Using Java? β€” same SDK, minor syntax differences.

Initialise once per process, fetch the tenant's published notice, show the Compose screen if no decision is on file yet, and record whatever the user chooses.

Application.kt
class MyApp : Application() {
    lateinit var shield: ShieldConsentManager

    override fun onCreate() {
        super.onCreate()
        shield = ShieldConsentManager.init(
            applicationContext,
            ShieldConsentManager.Config(
                apiKey = "dpdpa_live_YOUR_KEY",
                cmpId = 1234, // your IAB CMP ID, if registered - see IAB TCF v2 below
            ),
        )
        // Flushes the offline write queue automatically whenever
        // connectivity returns - call once, for the process lifetime.
        shield.observeConnectivity()
    }
}
MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val shield = (application as MyApp).shield

        setContent {
            var notice by remember { mutableStateOf<ConsentNotice?>(null) }
            var localizedNotice by remember { mutableStateOf<LocalizedNotice?>(null) }

            LaunchedEffect(Unit) {
                if (shield.restoredDecision() == null) {
                    when (val result = shield.loadNotice()) {
                        is ShieldApiResult.Success -> {
                            notice = result.value
                            localizedNotice = shield.localize()
                        }
                        is ShieldApiResult.Failure -> { /* show retry UI */ }
                    }
                }
            }

            localizedNotice?.let { localized ->
                // Build theme from notice branding β€” logo slot is a composable
                // lambda so you choose the image loader (Coil, Glide, etc.).
                // hexToComposeColor() returns null for non-white-label tenants
                // and the screen falls back to your app's MaterialTheme primary.
                val theme = ShieldConsentTheme(
                    primaryColor  = hexToComposeColor(notice?.primaryColor),
                    showPoweredBy = notice?.brandName == null,
                    logo = notice?.logoUrl?.let { url ->
                        {
                            AsyncImage(   // io.coil-kt:coil-compose in your build.gradle
                                model              = url,
                                contentDescription = notice?.brandName,
                                modifier           = Modifier.height(40.dp),
                            )
                        }
                    },
                )
                ConsentScreen(
                    notice       = localized,
                    theme        = theme,
                    contactEmail = notice?.contactEmail,
                    brandName    = notice?.brandName,
                    onAcceptAll  = { given -> shield.recordDecision("user@example.com", given, "en") },
                    onRejectAll  = { given -> shield.recordDecision("user@example.com", given, "en") },
                    onSave       = { given -> shield.recordDecision("user@example.com", given, "en") },
                )
            }
        }
    }
}

The identifier is hashed on-device

The first argument to recordDecision(identifier, given, languageShown) is your user's email, phone, or user ID, in the clear. The SDK hashes it with the exact same SHA-256 normalisation the backend uses (trim + lowercase) before it ever touches the network or local storage - the raw value is never persisted, never queued, and never sent. For an anonymous/pre-login user, pass a stable per-install identifier instead (e.g. a random UUID you generate once and store yourself).

Optional 4th argument: externalId

recordDecision(identifier, given, languageShown, externalId) β€” unlike identifier, externalId is sent as-is, never hashed - it's YOUR own internal ID for this user (e.g. your users table primary key), not PII we need to protect. Pass it to correlate this consent record back to your own database later without re-hashing lookups: shield.recordDecision(user.email, given, "en", user.id). Optional and independent of identifier - omit it (or pass null) and nothing changes from today.


Using from Java

The SDK is a Kotlin library that compiles to standard JVM bytecode β€” a Java app imports the same com.dpdpashield.sdk:android Gradle dependency and calls it directly. All synchronous methods work identically. Two rough edges exist:

.Companion. prefix on init()

init() lives in a Kotlin companion object. Without the @JvmStatic annotation (not yet added), Java calls it as ShieldConsentManager.Companion.init(). Works correctly β€” just verbose. A future SDK release will add @JvmStatic to clean this up.

loadNotice() is suspend

suspend fun loadNotice() is awkward to call from Java. The recommended pattern is a single Kotlin bridge file in your project (shown below) β€” one file, ~10 lines, never needs to grow. Google recommends the same "thin Kotlin layer for coroutines" approach in its own Java migration guides.

Application.java β€” initialisation

All synchronous methods β€” recordDecision(), restoredDecision(), observeConnectivity() β€” work from Java with no adaptation.

MyApplication.java
public class MyApplication extends Application {
    public ShieldConsentManager shield;

    @Override
    public void onCreate() {
        super.onCreate();
        // .Companion. prefix until @JvmStatic ships in a future SDK release
        shield = ShieldConsentManager.Companion.init(
            getApplicationContext(),
            new ShieldConsentManager.Config(
                "dpdpa_live_YOUR_KEY",
                1234,   // cmpId
                1,      // cmpVersion (default)
                null    // tcfPurposeMapping (optional)
            )
        );
        // Flush the offline write queue automatically when connectivity returns
        shield.observeConnectivity();
    }
}

ShieldGate from Java

isConsented() is a plain boolean β€” use it directly if you prefer to avoid the lambda syntax. runIfConsented is a Kotlin inline fun that takes a lambda β€” callable from Java, but the lambda must return Unit.INSTANCE.

Anywhere in your Java code
ShieldConsentManager shield = ((MyApplication) getApplication()).shield;

// Option A β€” plain boolean check (simplest from Java)
if (shield.getGate().isConsented(analyticsPurposeId)) {
    initAnalyticsSdk();
}

// Option B β€” runIfConsented lambda
// Kotlin inline funs require Unit.INSTANCE as the explicit return value in Java
shield.getGate().runIfConsented(analyticsPurposeId, () -> {
    FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(true);
    return Unit.INSTANCE;
});

// Record a decision β€” works identically from Java (not a suspend fun)
Map<String, Boolean> purposes = new HashMap<>();
purposes.put(analyticsPurposeId, true);
purposes.put(marketingPurposeId, false);
shield.recordDecision("user@example.com", purposes, "en");

loadNotice() β€” the one-Kotlin-file bridge

Add one Kotlin file to your project. It wraps the suspend call with a lifecycle-aware coroutine and reports back to Java via a callback. This is the same thin-Kotlin-layer approach Google uses in its own Java-to-Kotlin migration guides.

ConsentBridge.kt β€” add one file to your Java project
import androidx.lifecycle.LifecycleCoroutineScope
import com.dpdpashield.sdk.android.ShieldConsentManager
import com.dpdpashield.sdk.core.i18n.LocalizedNotice
import com.dpdpashield.sdk.core.net.ShieldApiResult
import kotlinx.coroutines.launch

object ConsentBridge {
    @JvmStatic
    fun loadNotice(
        manager: ShieldConsentManager,
        scope: LifecycleCoroutineScope,
        onSuccess: (LocalizedNotice?) -> Unit,
        onError: (Throwable) -> Unit,
    ) {
        scope.launch {
            when (val result = manager.loadNotice()) {
                is ShieldApiResult.Success -> onSuccess(manager.localize())
                is ShieldApiResult.Failure -> onError(result.error)
            }
        }
    }
}
MainActivity.java β€” calling the bridge
import androidx.lifecycle.LifecycleOwnerKt;

// In onCreate() or wherever you check consent
ShieldConsentManager shield = ((MyApplication) getApplication()).shield;

if (shield.restoredDecision() == null) {
    ConsentBridge.loadNotice(
        shield,
        LifecycleOwnerKt.getLifecycleScope(this),
        notice -> runOnUiThread(() -> {
            if (notice != null) {
                // show your consent screen - notice.getPurposes() etc.
            }
        }),
        error -> Log.e("Shield", "loadNotice failed", error)
    );
}

Only loadNotice() needs the bridge

Everything else β€” init(), recordDecision(), restoredDecision(), ShieldGate, the WebView bridge β€” is synchronous or fire-and-forget and works from Java with no adapter. You only need the one Kotlin file above.


Parental Consent β€” DPDPA Section 9

For apps that collect data from users who may be under 18. The SDK provides a native 3-step Compose screen that handles age verification, guardian OTP dispatch, and OTP confirmation β€” no WebView, no custom flow to build. The backend is the same POST /children/parental-consent/* endpoints powering the DPO dashboard's Children's Data module.

Step 1
Age gate
Child enters DOB. SDK calls the age-gate endpoint. If under 18, proceeds to step 2.
Step 2
Guardian email
App collects parent/guardian email. SDK emails a 6-digit OTP (24h expiry).
Step 3
OTP confirmation
Guardian enters the OTP. On success, ChildAccount is created with default restrictions.

Drop-in Compose screen

// In MainActivity.kt β€” show ParentalConsentScreen when needed
val shield = (application as MyApp).shield
val sessionId = UUID.randomUUID().toString()

// Render the screen inside a dialog, sheet, or full-screen composable
ParentalConsentScreen(
    manager   = shield,
    sessionId = sessionId,
    orgName   = "Acme App",
    onVerified = { result ->
        // result.childAccountId β€” store if you need it
        // result.restrictions  β€” ["AD_TARGETING","PROFILING","DATA_SHARING","BEHAVIORAL_TRACKING"]
        // Proceed: child has now been verified + restrictions applied
        showMainContent()
    },
    onCancel = {
        // User cancelled or is an adult (adult flow: show normal ConsentScreen instead)
        showNormalConsent()
    },
    initialDob = "",    // omit to show the DOB field; pre-fill if you already know it
)

Manual step-by-step (if you build your own UI)

If you want full control over the UI, call the three methods on ShieldConsentManager directly instead.

// Step 1 β€” verify age
val ageResult = shield.verifyAge(dob = "2010-06-15", sessionId = mySessionId)
if (ageResult is ShieldApiResult.Success && ageResult.value.isMinor) {
    // Show guardian email input
}

// Step 2 β€” send OTP to guardian
val initiated = shield.initiateParentalConsent(
    sessionId    = mySessionId,
    guardianEmail = "parent@example.com",
)
// initiated.value.consentId β€” keep this for step 3
// initiated.value.maskedGuardianEmail β€” show "OTP sent to par***@example.com"

// Step 3 β€” verify OTP
val verified = shield.verifyParentalConsent(
    consentId = initiated.value.consentId,
    otp       = "123456",           // from the guardian's email
)
// verified.value.childAccountId β€” ChildAccount created, restrictions active

Default restrictions applied automatically

On successful verification the backend creates a ChildAccount with four processing restrictions automatically applied: AD_TARGETING, PROFILING, DATA_SHARING, and BEHAVIORAL_TRACKING. Gate your ad/analytics initialisation on these using ShieldGate.runIfConsented().


Gating third-party SDKs on consent

Most consent SDKs document gating as a discipline the app developer has to remember. ShieldGate makes it a call you actually wrap third-party init code in - and it is fail-closed: a purpose with no recorded decision is treated as not consented, never as consented-by-default.

// Runs the block only if the purpose is currently consented - reacts
// immediately to the gate flipping, online or offline.
shield.gate.runIfConsented(analyticsPurposeId) {
    FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(true)
}

// Or observe changes over time - fires immediately with current state,
// then again on every future decision update (e.g. the user later
// grants a purpose they'd initially declined).
shield.gate.observeConsented(analyticsPurposeId) { consented ->
    if (consented) initAnalyticsSdk() else disableAnalyticsSdk()
}

purposeId is the same UUID from your Processing Purpose registry (Consent β†’ Purposes in the dashboard) that the Web SDK and notice builder already use - there is no separate mobile purpose list.


App Identity - the mobile equivalent of the domain allowlist

A native request carries neither an Origin nor a Referer header the way a browser does, so the SDK identifies itself instead with your app's package name and the SHA-256 fingerprint of the certificate it was signed with - read straight from Android's own PackageManager, the same fingerprint shown on your Play Console listing's "App integrity" page. This is computed automatically by ShieldConsentManager.init() and sent as an X-App-Identity: packageName:fingerprint header - there is nothing for you to compute or configure on the app side.

Unenforced by default

An API key with zero registered app identities lets any app through, exactly like an API key with zero registered domains lets any website through today. Lock a key down once you're ready to restrict it to specific apps - not before.

Registering your app (dashboard admin API)

Requires a DPO or Analyst Bearer token - not something the mobile app itself calls. Do this once per package name + signing certificate (debug and release keystores each need their own row).

POST/api/v1/api-keys/:keyId/app-identities
ParameterTypeRequiredDescription
packageNamestringRequiredAndroid applicationId, e.g. com.example.myapp.
certificateFingerprintstringRequiredSHA-256 signing certificate fingerprint, either as 64 bare hex characters or the colon-separated form Android Studio / Play Console display.
platformstringOptionalANDROID (default) or IOS (reserved for a future SDK).
GET/api/v1/api-keys/:keyId/app-identities

List registered apps for a key.

DELETE/api/v1/api-keys/:keyId/app-identities/:id

Remove a registered app.

Error codes

APP_IDENTITY_MISSINGThe key has at least one registered app, but this request sent no X-App-Identity header at all.
APP_IDENTITY_MALFORMEDThe header was present but not in packageName:fingerprint shape.
APP_IDENTITY_NOT_ALLOWEDWell-formed, but this package name + fingerprint pair isn't registered for this key.

WebView sync β€” prevent duplicate consent banners

If your app embeds your own website in a WebView and that page runs the Web SDK, use this bridge so the web banner is suppressed β€” the native decision you already captured is injected into the page's localStorage before the SDK runs. This is not how you collect consent inside a WebView β€” the Compose screen above does that natively. This is purely for syncing state into an embedded page.

class MyWebViewActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val shield = (application as MyApp).shield
        val webView = WebView(this)
        val bridge = ShieldWebViewBridge(shield)

        bridge.install(webView) // registers the JS interface, once
        webView.webViewClient = object : WebViewClient() {
            override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
                bridge.onPageStarted(view) // before the page's own scripts run
            }
        }
        webView.loadUrl("https://yourdomain.com/support")
    }
}

If the embedded page instead records a decision itself (the WebView is the primary UI for that screen), it calls back into window.ShieldNative.reportConsent(...) automatically, which syncs your native ShieldGate only - it deliberately does not queue a second backend write, since the Web SDK already made its own call.


IAB TCF v2 - free interop with ad/analytics SDKs

Every decision is also written as an IABTCF_TCString (a spec-correct TCF v2 Core String) in your app's plain SharedPreferences, so any third-party SDK that already speaks the industry-standard in-app TCF format reads real consent state with zero extra code from you.

Purpose mapping is opt-in - never guessed

Your own ProcessingPurpose UUIDs and IAB's fixed 1-24 purpose numbers are two different vocabularies for two different regimes. Without an explicit mapping, the purpose-consent bitfield stays empty rather than guessed - a fabricated mapping would hand every reading SDK a false compliance signal.

ShieldConsentManager.Config(
    apiKey = "dpdpa_live_YOUR_KEY",
    cmpId = 1234,
    tcfPurposeMapping = TcfPurposeMapping(mapOf(
        "your-marketing-purpose-uuid" to 4,  // IAB Purpose 4: Ad selection
        "your-analytics-purpose-uuid" to 8,  // IAB Purpose 8: Market research
    )),
)

IABTCF_gdprApplies is always written as "0" - DPDPA 2023 is not GDPR, and this SDK never claims otherwise to a reading SDK.


Backend endpoints used by the SDK

Called automatically by ShieldConsentManager - you do not call these directly in normal usage. Same X-API-Key auth as the Web SDK, plus the X-App-Identity header described above.

GET/api/v1/consent/public-notice

Fetches the published notice and purposes for the tenant, including all 22-language translations. Called by loadNotice(). Identical response shape to the Web SDK's call to the same endpoint.

POST/api/v1/consent/sdk-record

Records the decision. Called by the offline queue's flush, never directly by recordDecision() itself - the gate flips locally first, and this write is queued with exponential backoff so a flaky connection never blocks enforcement. On success, the ConsentAuditLog channel is set to MOBILE_APP, distinguishing it from web/API/server-side writes in every export and analytics breakdown.


Common issues

Need help integrating?

This SDK is pre-release - we'll walk through your integration directly.

Email hello@dpdpashield.in β†’