Android SDK
v1.0.0-beta3Kotlin & 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.
π€ Native Android
Writing Kotlin or Java? This page.
π± React Native / Expo
TypeScript, one file to copy β no native module needed.
π¦ Flutter
Dart plugin β bridge over this Kotlin core.
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
- Log in to
dpdpashield.in, copy adpdpa_live_API key with a published notice attached. - Publish this SDK to your local Maven cache (Installation, below) - a one-time build step, no ongoing network dependency on this repo.
- Create or open a host Android app, add the dependency, point it at
https://api.dpdpashield.in. - Run it - emulator or physical device - call
loadNotice(), render the screen, tap Accept. - 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.
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}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.
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()
}
}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.
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.
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.
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)
}
}
}
}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.
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 activeDefault 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).
/api/v1/api-keys/:keyId/app-identities| Parameter | Type | Required | Description |
|---|---|---|---|
packageName | string | Required | Android applicationId, e.g. com.example.myapp. |
certificateFingerprint | string | Required | SHA-256 signing certificate fingerprint, either as 64 bare hex characters or the colon-separated form Android Studio / Play Console display. |
platform | string | Optional | ANDROID (default) or IOS (reserved for a future SDK). |
/api/v1/api-keys/:keyId/app-identitiesList registered apps for a key.
/api/v1/api-keys/:keyId/app-identities/:idRemove 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.
/api/v1/consent/public-noticeFetches 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.
/api/v1/consent/sdk-recordRecords 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 βOn this page