Skip to content

Enhance consent with industry standards, build-time transforms, and purpose-based UI#13

Open
kasnder wants to merge 23 commits intomasterfrom
claude/enhance-consent-standards-obiZd
Open

Enhance consent with industry standards, build-time transforms, and purpose-based UI#13
kasnder wants to merge 23 commits intomasterfrom
claude/enhance-consent-standards-obiZd

Conversation

@kasnder
Copy link
Copy Markdown
Member

@kasnder kasnder commented Apr 4, 2026

Summary

Major overhaul to make auto-app-consent a robust, standards-compliant consent management tool — comparable to commercial CMPs but free, open-source, and with actual code-level enforcement.

Industry Standard Consent Signals

  • IAB TCF v2.2: Writes all IABTCF_* keys to SharedPreferences including a proper base64url-encoded TC String (IABTCF_TCString) — the encoded consent record that SDKs like Google Ads check first
  • IAB US Privacy (CCPA): Writes IABUSPrivacy_String (e.g. 1YNN, 1YYN)
  • Global Privacy Control: Sec-GPC: 1 header injected across all HTTP stacks:
    • OkHttp3, OkHttp2, Cronet — via build-time bytecode transforms
    • HttpURLConnection — via URLStreamHandlerFactory (standard Java API)
    • WebViews — via GpcWebViewClient (header + navigator.globalPrivacyControl)
  • Google Consent Mode v2: Sets ad_storage, analytics_storage, ad_user_data, ad_personalization via FirebaseAnalytics.setConsent(). Required for EU ad serving since March 2024.

Build-Time Bytecode Transforms (replaces YAHFA runtime hooking)

  • New Gradle plugin (net.kollnig.consent.plugin) uses AGP's AsmClassVisitorFactory to inject consent checks into SDK bytecode at compile time
  • Works on all Android versions, no Play Protect flags, no ART/JIT issues
  • Transforms: Google Ads (initialize, loadAd), Advertising ID (getAdvertisingIdInfo → throws IOException), AppsFlyer (start), Flurry (build), InMobi (init), AdColony (configure), Vungle (init)
  • Adding a new SDK is one line in ConsentTransformRules.java

Purpose-Based Consent UI

  • Dialog now shows purposes (TCF-aligned categories) instead of individual SDK names:
    • Analytics, Advertising, Crash Reporting, Social Features, Cross-App Tracking
  • Each purpose maps to specific TCF purpose IDs and SDK library IDs
  • Purpose consent propagates to all associated SDKs + all standards signals

Runtime Hooking Removed

  • Removed YAHFA dependency entirely (was Android 7-12 only, abandoned)
  • No LSPlant, no Pine, no runtime method hooking at all
  • Library classes reduced from 80-170 lines to 30-70 lines each

CI

  • GitHub Actions workflow with plugin unit tests (53 tests) and library unit tests
  • Plugin tests verify bytecode validity for every SDK signature using ASM's CheckClassAdapter

Test Plan

  • Plugin tests pass (./gradlew test in plugin/) — 53 tests covering transform rules, bytecode generation, class visitor routing, and GPC injection
  • Library tests pass (./gradlew :library:testDebugUnitTest) — TCF, US Privacy, GPC, TC String encoder tests
  • Example app builds with consent plugin applied
  • Consent dialog shows purpose categories, not SDK names
  • TCF signals written to default SharedPreferences (verify with adb shell run-as <pkg> cat shared_prefs/<pkg>_preferences.xml)
  • Google Consent Mode v2 signals set when Firebase Analytics present

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa

claude added 23 commits April 4, 2026 13:17
Integrate industry-standard consent signals to make the library more
robust and reduce reliance on method hooking:

- IAB TCF v2.2: Writes IABTCF_ keys to SharedPreferences that most ad
  SDKs already read natively, reducing need for YAHFA hooks
- IAB US Privacy (CCPA): Writes IABUSPrivacy_String for CCPA compliance
- GPC: Adds Sec-GPC:1 header support via HttpURLConnection helper,
  OkHttp interceptor (reflection-based, no hard dep), and WebViewClient
  that injects navigator.globalPrivacyControl
- Reject All button enabled in consent dialog
- New builder options: enableTcf(), enableUsPrivacy(), enableGpc(),
  setGdprApplies(), setCcpaApplies(), setPublisherCountryCode()

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Hook URL.openConnection() and OkHttp RealCall.execute()/AsyncCall to
inject Sec-GPC: 1 header into ALL outgoing HTTP requests, regardless
of which SDK initiates them. This ensures every ad, analytics, and
attribution library sends the GPC signal — not just libraries we
explicitly integrate with.

Hooks:
- URL.openConnection() — catches all HttpURLConnection-based traffic
- OkHttp RealCall.execute() — catches sync OkHttp requests
- OkHttp AsyncCall.execute() — catches async OkHttp requests

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
The YAHFA-based network hooks (URL.openConnection, OkHttp RealCall) are
fragile: they only work on Android 7-12, break across OEM skins, and
can trigger Google Play Protect warnings.

New approach: GpcLocalProxy runs a lightweight local HTTP proxy on
127.0.0.1 that injects Sec-GPC:1 into outgoing HTTP requests. It uses
JVM system properties (http.proxyHost/Port) so all HTTP clients route
through it automatically. This works on all Android versions with no
ART hooking.

- PROXY mode (new default): local proxy, stable, no hooking
- HOOK mode (opt-in): YAHFA hooks, broader coverage but fragile
- Builder: .enableGpc() uses PROXY, .enableGpc(GpcMode.HOOK) for YAHFA
- Proxy auto-falls-back to hook mode if it fails to start

Limitation: proxy only injects headers for plain HTTP; HTTPS uses
CONNECT tunneling (can't modify encrypted headers). For HTTPS, the
TCF/US Privacy SharedPreferences signals are the correct approach
since SDKs read consent locally before making requests.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Major stability improvements:

1. **YAHFA -> LSPlant**: LSPlant (from LSPosed) supports Android 5.0-16+,
   handles JIT inlining, and is actively maintained. YAHFA only worked on
   Android 7-12 and is abandoned. All 7 hooked library classes migrated.
   New HookCompat wrapper provides clean API with backup method dispatch.

2. **Remove GPC network interceptors**: Deleted GpcNetworkInterceptor
   (YAHFA hooks on URL.openConnection/OkHttp), GpcLocalProxy (HTTP proxy),
   and GpcOkHttpInterceptor. These approaches cannot work for HTTPS with
   cert pinning, which is what all major ad SDKs use. GPC is correctly
   scoped to where it actually works: WebViews (GpcWebViewClient) and
   the app's own HTTP requests (GpcInterceptor.applyTo()).

3. **compileSdk/targetSdk 32 -> 35**: Updated for modern Android support.

For third-party SDK consent signaling, TCF v2.2 and US Privacy strings
in SharedPreferences are the correct mechanism — SDKs read these locally
before making any network requests.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
New Gradle plugin (net.kollnig.consent.plugin) that uses the Android
Gradle Plugin's AsmClassVisitorFactory to inject consent checks into
third-party SDK bytecode at compile time. This eliminates runtime
hooking entirely for supported SDKs.

How it works:
- ConsentTransformRules defines which SDK methods to transform
- ConsentClassVisitorFactory/ConsentClassVisitor match target classes
- ConsentMethodVisitor injects consent check bytecode at method entry:
    if (!Boolean.TRUE.equals(ConsentManager.getInstance()
        .hasConsent("library_id"))) return;

Advantages over LSPlant/YAHFA runtime hooking:
- Works on ALL Android versions (no ART dependency)
- No Google Play Protect flags
- No OEM/JIT/inlining issues
- Consent check baked into APK at build time

Currently transforms: Google Ads (initialize, loadAd), Advertising ID
(getAdvertisingIdInfo), AppsFlyer (start), Flurry (build), InMobi
(init), AdColony (configure), Vungle (init).

The runtime hooks (LSPlant via HookCompat) remain as a fallback for
SDKs where build-time transformation isn't feasible (e.g., obfuscated
method names that change between versions).

Usage: apply `id 'net.kollnig.consent.plugin'` in app/build.gradle.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Three CI jobs in GitHub Actions:

1. Plugin unit tests (pure JVM, no Android SDK needed):
   - ConsentTransformRulesTest: verifies rule matching for all 8 SDK classes
   - ConsentClassVisitorTest: verifies only matching methods are transformed
   - ConsentMethodVisitorTest: generates bytecode for each SDK signature and
     validates with ASM CheckClassAdapter (structural validity, correct
     consent manager references, correct return types, ATHROW for AAID)

2. Library unit tests (Robolectric):
   - TcfConsentManagerTest: TCF v2.2 SharedPreferences keys, per-purpose
     granularity, GDPR applicability, clear/read operations
   - UsPrivacyManagerTest: US Privacy string format (1YNN/1YYN/1---),
     opt-out detection, clear operations
   - GpcInterceptorTest: enable/disable, header injection on HttpURLConnection

3. Bytecode transform verification:
   - Builds plugin and runs realSignature_* tests that verify transforms
     produce valid bytecode for every actual SDK method signature
   - Checks that test coverage matches rule count

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Tests were failing because:
1. Gradle 7.3.3 can't run on Java 21 (class file version 65)
2. Plugin tests couldn't resolve gradle-api from Google Maven

Fixes:
- CI uses JDK 17 (compatible with Gradle 7.3.3)
- Plugin excludes AGP-dependent source files (ConsentPlugin,
  ConsentClassVisitorFactory) from compilation — these only need
  AGP at runtime when applied to an Android project
- Core transform logic (rules, visitors, method visitor) compiles
  and tests with only ASM — no Android SDK needed
- Plugin has its own settings.gradle for standalone builds
- Removed duplicate plugin.properties (auto-generated by gradlePlugin block)
- Library tests use Robolectric with includeAndroidResources

All 40 plugin tests pass locally (18 rules + 18 bytecode + 4 routing).

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
The plugin was excluding ConsentPlugin.java and ConsentClassVisitorFactory.java
from compilation, so the jar didn't contain the implementation class that
gradlePlugin descriptor referenced — causing "Could not find implementation
class" when the app module tried to apply the plugin.

Fixes:
- Restore gradle-api as implementation dependency (not compileOnly)
- Remove sourceSets exclusion — all files compile when AGP is available
- Add Gradle wrapper to plugin/ for standalone CI builds
- Plugin tests use plugin's own ./gradlew from plugin/ working directory

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
transformClassesWith and setAsmFramesComputationMode are on the
Instrumentation object (variant.getInstrumentation()), not directly
on Variant. Also fix generic type cast for AndroidComponentsExtension.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
AGP's transformClassesWith requires 3 args: factory class, scope, and
a configuration Function1<ParamT, Unit>. Since we use
InstrumentationParameters.None, pass a no-op Kotlin lambda.
Add kotlin-stdlib dependency for Unit type.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
- Use raw AndroidComponentsExtension type and cast variant to Variant
  in the lambda to access getInstrumentation()
- Import kotlin.Unit and return Unit.INSTANCE from config lambda
- Remove unused InstrumentationParameters import

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
LSPlant is a pure native C++ library with no Java API — its classes.jar
is empty, so `import org.lsposed.lsplant.LSPlant` fails at compile time.

Pine (top.canyie.pine) provides a proper Java API:
- Pine.hook(Method, MethodHook) for intercepting methods
- Pine.invokeOriginalMethod(Member, Object, Object...) for calling originals
- Supports Android 5.0-14+

HookCompat now uses Pine's callback model: backupAndHook() installs a
Pine MethodHook that delegates to the Library class's static hook method,
and callOriginal() uses Pine.invokeOriginalMethod().

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Every SDK that previously needed YAHFA/LSPlant/Pine runtime hooks is now
handled by the build-time bytecode transform plugin, which injects
consent checks at compile time. The remaining SDKs (Firebase, Crashlytics,
Facebook, AppLovin, ironSource) use simple reflection calls that never
needed hooks.

Removed:
- Pine dependency (top.canyie.pine:core)
- HookCompat.java
- All static replacement/original stub methods from 7 library classes
- All initialise() hook setup code

The library classes now only contain:
- getId(), getBaseClass(), getName(), getConsentMessage() — metadata
- passConsentToLibrary() — reflection-based consent calls where needed

This eliminates the #1 source of fragility in the project. No more
ART version dependencies, no Play Protect flags, no OEM breakage.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
The old README described the YAHFA-based approach which no longer exists.
New README covers:
- Three-level architecture (standards, build-time transforms, SDK config)
- Installation with the Gradle plugin
- All builder options (TCF, US Privacy, GPC, GDPR/CCPA flags)
- How the bytecode transform works with example
- How to add new SDKs (one line in ConsentTransformRules)
- Architecture diagram
- Per-SDK implementation details
- Comparison table with existing solutions

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Transform okhttp3.Request.Builder.build() at compile time to inject
Sec-GPC: 1 header into all OkHttp-based HTTP requests. Since most ad
SDKs bundle OkHttp internally, this catches their HTTP traffic.

How it works:
- New INJECT_GPC_HEADER action in ConsentTransformRules
- ConsentMethodVisitor injects: if (GpcInterceptor.isEnabled())
    this.header("Sec-GPC", "1");
  at the start of Request.Builder.build()
- Header is added before TLS, so cert pinning is irrelevant
- Guarded by GpcInterceptor.isEnabled() — only active when
  .enableGpc() is called in ConsentManager.Builder

This is different from our earlier runtime hooking approach:
- Build-time transform = stable across all Android versions
- Modifies SDK bytecode at compile time, not ART methods at runtime
- No Play Protect flags, no JIT breakage

Tests: 5 new tests (valid bytecode, GpcInterceptor reference,
Sec-GPC string present, no ConsentManager reference, real signature).
All 47 tests pass.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Extend build-time GPC header injection to cover three HTTP client stacks:

- OkHttp3 (okhttp3.Request.Builder) — most modern ad SDKs
- OkHttp2 (com.squareup.okhttp.Request.Builder) — older SDKs
- Cronet (org.chromium.net.UrlRequest.Builder) — Google SDKs

The injection is now generic: each GPC rule specifies its own header
method name and descriptor (e.g. "header" for OkHttp, "addHeader" for
Cronet), so ConsentMethodVisitor doesn't hardcode any HTTP client.

Volley is not transformed (no builder pattern — uses getHeaders()
override), but is covered by app-level GpcInterceptor.applyTo().

All 53 tests pass (24 rules + 25 bytecode + 4 routing, 0 failures).

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
HttpURLConnection is a framework class that AGP can't transform at
build time. Instead, use Java's built-in URLStreamHandlerFactory API
to wrap every HttpURLConnection with the Sec-GPC: 1 header.

This is a stable, documented Java API — not a runtime hook:
- URL.setURLStreamHandlerFactory() registers a custom handler
- The handler wraps the default and adds the GPC header
- Works on all Android versions

GPC coverage is now complete:
- OkHttp3/OkHttp2/Cronet → build-time bytecode transform
- HttpURLConnection → URLStreamHandlerFactory (this commit)
- WebViews → GpcWebViewClient
- App's own requests → GpcInterceptor.applyTo()

Limitation: setURLStreamHandlerFactory() can only be called once per
JVM. If another library already set it, we log a warning and skip.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
The previous implementation had an infinite recursion bug: calling
URL.openConnection() inside the factory would route back through the
factory. Fix by:

1. Resolving the platform's default HTTP/HTTPS handlers via reflection
   BEFORE installing our factory
2. Delegating to those cached handlers instead of creating new URLs

Uses reflection on URLStreamHandler.openConnection() (protected method)
and URL.handler field to extract the platform handlers. This is the
approach used by other production libraries that wrap URLStreamHandler.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Add table showing how GPC headers are injected for each HTTP client
(OkHttp3, OkHttp2, Cronet, HttpURLConnection, WebViews). Update
architecture diagram and implementation details to reflect full
GPC coverage.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Many SDKs (notably Google Ads) check IABTCF_TCString before reading
the individual IABTCF_PurposeConsents fields. Without a valid TC
String, those SDKs may ignore all other TCF signals.

The TC String is a base64url-encoded binary format per the IAB spec:
- Version (6 bits) = 2
- Created/LastUpdated (36 bits each) = deciseconds since TCF epoch
- CmpId, CmpVersion, ConsentScreen
- ConsentLanguage, PublisherCC (6-bit letter encoding)
- PurposesConsent (24 bits), PurposesLITransparency (24 bits)
- SpecialFeatureOptIns (12 bits)
- Vendor consent/LI sections (empty — no specific vendor mapping)
- Publisher restrictions (none)

TcStringEncoder provides:
- encode(cmpId, version, lang, cc, consent) — blanket consent/deny
- encode(..., purposes[], purposesLI[], specialFeatures[], gvlVersion)
  — per-purpose granularity

TcfConsentManager now writes IABTCF_TCString alongside all other keys.

Tests: 13 new tests for TcStringEncoder (version bits, base64url
validity, bit writer correctness, language encoding), 4 new tests
for TcfConsentManager (TC string written, differs for consent/deny,
per-purpose, cleared on reset).

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
Major changes:

1. Purpose-based consent dialog:
   Instead of listing individual SDKs (InMobi, AppsFlyer, etc.), the
   dialog now shows TCF-aligned PURPOSES:
   - Analytics — Firebase Analytics, Flurry, AppsFlyer
   - Advertising — Google Ads, AppLovin, InMobi, AdColony, ironSource, Vungle
   - Crash Reporting — Crashlytics
   - Social Features — Facebook SDK
   - Cross-App Tracking — Advertising ID

   Users consent to purposes, not vendors. Each purpose maps consent
   to all its associated SDKs.

2. Google Consent Mode v2:
   New GoogleConsentMode class that calls FirebaseAnalytics.setConsent()
   via reflection to set ad_storage, analytics_storage, ad_user_data,
   and ad_personalization signals. Required for EU ad serving since
   March 2024. Enabled via .enableGoogleConsentMode() in Builder.

3. Per-purpose TCF signal mapping:
   Purpose consent decisions are mapped to specific TCF purpose IDs:
   - Analytics → TCF purposes 7, 8, 9
   - Advertising → TCF purposes 2, 3, 4
   - Crash Reporting → TCF purpose 10
   - Social → TCF purpose 1
   - Identification → TCF Special Feature 2

4. Backward compatibility:
   hasConsent(libraryId) still works for build-time transforms.
   New hasPurposeConsent(purposeId) for purpose-level queries.

https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa
@kasnder kasnder changed the title Add IAB TCF v2.2, US Privacy (CCPA), and Global Privacy Control support Enhance consent with industry standards, build-time transforms, and purpose-based UI Apr 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants