Enhance consent with industry standards, build-time transforms, and purpose-based UI#13
Open
Enhance consent with industry standards, build-time transforms, and purpose-based UI#13
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
IABTCF_*keys to SharedPreferences including a proper base64url-encoded TC String (IABTCF_TCString) — the encoded consent record that SDKs like Google Ads check firstIABUSPrivacy_String(e.g.1YNN,1YYN)Sec-GPC: 1header injected across all HTTP stacks:URLStreamHandlerFactory(standard Java API)GpcWebViewClient(header +navigator.globalPrivacyControl)ad_storage,analytics_storage,ad_user_data,ad_personalizationviaFirebaseAnalytics.setConsent(). Required for EU ad serving since March 2024.Build-Time Bytecode Transforms (replaces YAHFA runtime hooking)
net.kollnig.consent.plugin) uses AGP'sAsmClassVisitorFactoryto inject consent checks into SDK bytecode at compile timeinitialize,loadAd), Advertising ID (getAdvertisingIdInfo→ throws IOException), AppsFlyer (start), Flurry (build), InMobi (init), AdColony (configure), Vungle (init)ConsentTransformRules.javaPurpose-Based Consent UI
Runtime Hooking Removed
CI
CheckClassAdapterTest Plan
./gradlew testin plugin/) — 53 tests covering transform rules, bytecode generation, class visitor routing, and GPC injection./gradlew :library:testDebugUnitTest) — TCF, US Privacy, GPC, TC String encoder testsadb shell run-as <pkg> cat shared_prefs/<pkg>_preferences.xml)https://claude.ai/code/session_01WMg2iGmFmvsrcQsYe3TKDa