diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5143e6f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + pull_request: + branches: [ main, master ] + +jobs: + plugin-tests: + name: Plugin Unit Tests (bytecode transforms) + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-plugin-${{ hashFiles('plugin/**') }} + restore-keys: ${{ runner.os }}-gradle-plugin- + + - name: Run plugin tests + run: ./gradlew test --no-daemon + + - name: Verify all SDK signatures have tests + run: | + RULE_COUNT=$(grep -c 'addRule(' src/main/java/net/kollnig/consent/plugin/ConsentTransformRules.java) + TEST_COUNT=$(grep -c 'realSignature_' src/test/java/net/kollnig/consent/plugin/ConsentMethodVisitorTest.java) + echo "Transform rules: $RULE_COUNT, Signature tests: $TEST_COUNT" + if [ "$TEST_COUNT" -lt "$RULE_COUNT" ]; then + echo "::warning::Not all SDK rules have signature verification tests ($TEST_COUNT < $RULE_COUNT)" + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: plugin-test-results + path: plugin/build/reports/tests/ + + library-tests: + name: Library Unit Tests (TCF, US Privacy, GPC) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-library-${{ hashFiles('library/**', 'build.gradle', 'settings.gradle') }} + restore-keys: ${{ runner.os }}-gradle-library- + + - name: Run library unit tests + run: ./gradlew :library:testDebugUnitTest --no-daemon + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: library-test-results + path: library/build/reports/tests/ diff --git a/.gitignore b/.gitignore index ef73cf4..b7da9ed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.idea .DS_Store /build +/plugin/build /captures .externalNativeBuild .cxx diff --git a/README.md b/README.md index d583751..32405d5 100644 --- a/README.md +++ b/README.md @@ -1,282 +1,237 @@ -# Auto App Consent for App Developers (Alpha release) +# Auto App Consent for App Developers *Developed by Konrad Kollnig, Department of Computer Science, University of Oxford* -This repository shall help *app developers* implement consent in apps more easily. This helps -compliance with the GDPR, CCPA and other legal regimes. The motivation for this project that [our research at Oxford](https://www.usenix.org/conference/soups2021/presentation/kollnig) found that less than 4% of Android apps implement any form of consent. It puts our previous compliance guide from the website into code. Unfortunately, most existing mobile consent solutions are [limited in their functionality, costly, and may not actually help with GDPR compliance](#existing-consent-solutions-for-mobile). +This library helps Android app developers implement consent and privacy compliance. It addresses the finding from [our research at Oxford](https://www.usenix.org/conference/soups2021/presentation/kollnig) that less than 4% of Android apps implement any form of consent. -Specifically, this tool targets the following common compliance problems: +The library works at three levels: -**Common Problem 1:** Failure to to implement any consent flows. This can both involve 1) the sharing of data with third-party companies without consent (violating Articles 7 and (35)1 GDPR) and 2) non technically-necessary accessing or storing of data on smartphone (violating Article 5(3) ePrivacy Directive) +1. **Industry-standard consent signals** — Writes IAB TCF v2.2 and US Privacy (CCPA) strings to SharedPreferences, which most ad SDKs read natively +2. **Build-time bytecode transforms** — A Gradle plugin injects consent checks into SDK methods at compile time (no runtime hooking) +3. **SDK-specific configuration** — Sets manifest flags and calls SDK consent APIs via reflection -**Solution:** Automatic implementation of consent flows with this library. +## Supported Standards -**Common Problem 2:** Sharing more data than necessary (violating Article 4(1) GDPR). - -**Solution:** Many third-party libraries come with configuration options to reduce data collection. This library automatically chooses some of the most common settings. - - +| Standard | What it does | How it works | +|---|---|---| +| **IAB TCF v2.2** | GDPR consent signal for ad SDKs | Writes `IABTCF_*` keys to SharedPreferences — SDKs read these natively | +| **IAB US Privacy** | CCPA "do not sell" signal | Writes `IABUSPrivacy_String` to SharedPreferences | +| **Global Privacy Control** | Browser/web "do not sell" signal | `Sec-GPC: 1` header for WebViews and app HTTP requests | ## Supported SDKs -At the moment, this project automatically implements a consent flow if your app uses one of the following SDKs: - -- Google Firebase Analytics -- Google Crashlytics -- Google Ads -- Facebook SDK -- AppLovin -- Flurry (disabled SDK if lack of consent altogether) -- InMobi -- AppsFlyer -- ironSource -- AdColony -- Vungle (untested) -- Google Play Services Advertising Identifier Library - -*Note that the use of Google and Facebook services in the EU is [likely illegal even with user consent](https://noyb.eu/en/austrian-dsb-eu-us-data-transfers-google-analytics-illegal), because data is sent to the US and can be used for unlawful surveillance of EU citizens. The same applies to other US-based services.* +| SDK | Consent mechanism | +|---|---| +| Google Firebase Analytics | Reflection (`setAnalyticsCollectionEnabled`) + manifest flags | +| Google Crashlytics | Reflection (`setCrashlyticsCollectionEnabled`) + manifest flag | +| Google Ads | Build-time transform (blocks `initialize`, `loadAd`) + manifest flag | +| Facebook SDK | Reflection (`setAutoInitEnabled`, `setAutoLogAppEventsEnabled`) + manifest flags | +| AppLovin | Reflection (`setDoNotSell`, `setHasUserConsent`) | +| Flurry | Build-time transform (blocks `build`) | +| InMobi | Build-time transform (blocks `init`) | +| AppsFlyer | Build-time transform (blocks `start`) + reflection (`stop`) | +| ironSource | Reflection (`setConsent`, `setMetaData`) | +| AdColony | Build-time transform (blocks `configure`) + reflection (`setAppOptions`) | +| Vungle | Build-time transform (blocks `init`) + reflection (`updateConsentStatus`) | +| Google Advertising ID | Build-time transform (throws `IOException` on `getAdvertisingIdInfo`) | ## Installation -**NOTE THAT THE USE OF THIS TOOL COMES AT YOUR OWN RISK. THIS TOOL CANNOT REPLACE AND DOES NOT PROVIDE *EXPERT LEGAL ADVICE*. IT IS CURRENTLY NOT MEANT FOR PRODUCTION USE, BEING AN ALPHA RELEASE.** +### 1. Add the library dependency + +Add the JitPack repo and library: -1. Add the JitPack repo: ```gradle -allprojects { - repositories { - ... - maven { url 'https://jitpack.io' } - } +// settings.gradle +dependencyResolutionManagement { + repositories { + // ... + maven { url 'https://jitpack.io' } + } } -``` -2. Add the library: -```gradle + +// app/build.gradle dependencies { - implementation 'com.github.kasnder:app-consent-android:0.8' + implementation 'com.github.kasnder:app-consent-android:0.8' } ``` -3. Initialise the library by calling -```java -ConsentManager consentManager = - new ConsentManager.Builder(this) - .setPrivacyPolicy(Uri.parse("http://www.example.org/privacy")) - .build(); -``` -4. If you want to, you can change the title (or message) in the consent flow by changing - the `consent_title` (or `consent_msg`) string. -5. If you want to exclude certain libraries from the consent flow (e.g. the opt-in to the use of the - Advertising ID), then use the `setExcludedLibraries()` method of the `ConsentManager.Builder`. - For example, for Firebase Analytics: `.setExcludedLibraries(new String[]{"firebase_analytics"})`. - You can see the identifiers of all currently managed libraries - through `consentManager.getManagedLibraries()`. -6. By extending the class `net.kollnig.consent.library.Library`, you can connect further - libraries. Use the `setCustomLibraries()` method of the `ConsentManager.Builder` to include them, - e.g. `.setCustomLibraries(new Library[]{new CustomLibrary()})`. See the directory `library/src/main/java/net/kollnig/consent/library/` for example implementations. - -You can check the example project in `app/` to see how the library is used. - -## Implementation Details - -### General Approach - -This tool interacts with third-party libraries in three ways: 1) by setting options in -the `AndroidManifest.xml` file, 2) by calling functions of the third-party library directly (through -Reflection), and 3) by intercepting method calls to the third-party library and either adding more -privacy-preserving options or preventing the call to that function altogether. - -The third method is the most invasive and only taken when no alternatives are available. It relies -on [YAHFA](https://github.com/PAGalaxyLab/YAHFA) (Yet Another Hook Framework for ART) to hook -functions of third-party libraries. Since YAHFA is only compatible with Android 7–12, lower Android -versions are not supported by the library. This might be addressed in future versions of this -library. - -The following gives more details on how this tool interacts with third-party libraries. - -*Note that the use of Google and Facebook services in the EU is [likely illegal even with user consent](https://noyb.eu/en/austrian-dsb-eu-us-data-transfers-google-analytics-illegal), because data is sent to the US and can be used for unlawful surveillance of EU citizens. The same applies to other US-based services.* -### Google Firebase Analytics +### 2. Apply the Gradle plugin (for build-time transforms) -**Purpose:** Analytics - -**How consent is implemented:** Automatic data collection upon the first app start is managed through the `setAnalyticsCollectionEnabled` setting. This prevents the collection of analytics without user consent. - -**Further reduced data collection:** The tool disables the settings `google_analytics_ssaid_collection_enabled` (to prevent the collection of the ANDROID_ID) and `google_analytics_default_allow_ad_personalization_signals` (to prevent the use of data for ads). If you need the sharing of analytics data for ads, you can add the following to your `` tag in your `AndroidManifest.xml` file: +```gradle +// settings.gradle +pluginManagement { + includeBuild('plugin') // or use published coordinates +} -```xml - +// app/build.gradle +plugins { + id 'com.android.application' + id 'net.kollnig.consent.plugin' +} ``` -**Uses hooks:** No - -**Further details:** - -### Google Crashlytics - -**Purpose:** Crash reporting - -**How consent is implemented:** Automatic data collection upon the first app start is prevented through the `setCrashlyticsCollectionEnabled` setting. This prevents the collection of crash reports without user consent. - -**Further reduced data collection:** None, except that the `firebase_crashlytics_collection_enabled` flag is set to `false` in the `AndroidManifest.xml` file to implement consent. - -**Uses hooks:** No - -**Further details:** - -### Google Ads - -**Purpose:** Ads - -**How consent is implemented:** If no consent is given, calls to the `init` and `loadAd` methods are blocked. This prevents communication with the Google Ads servers without user consent. As per Google’s consent policies, the use of Google Ads is only permitted with user consent (even of non-personalised ads). - -**Further reduced data collection:** None, except that the `com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT` flag is set to `false` in the `AndroidManifest.xml` file to implement consent. - -**Uses hooks:** Yes - -**Further details:** +The plugin automatically transforms SDK bytecode during compilation — no runtime hooking needed. -### Facebook SDK +### 3. Initialize the ConsentManager -**Purpose:** Various functionality, including analytics - -**How consent is implemented:** Automatic data collection upon the first app start is prevented through the `setAutoInitEnabled` and `setAutoLogAppEventsEnabled` settings. This prevents the collection of analytics without user consent. - -**Further reduced data collection:** None, except that the `com.facebook.sdk.AutoInitEnabled` and `com.facebook.sdk.AutoLogAppEventsEnabled` are flags set to `false` in the `AndroidManifest.xml` file to implement consent. - -**Uses hooks:** No - -**Further details:** - -### AppLovin - -**Purpose:** Ads - -**How consent is implemented:** Automatic data collection upon the first app start is prevented through the `setDoNotSell` and `setHasUserConsent` settings. - -**Further reduced data collection:** None - -**Uses hooks:** No - -**Further details:** - -### Flurry - -**Purpose:** Various functionality, including analytics - -**How consent is implemented:** If no consent is given, calls to the `build` method (from the `FlurryAgent.Builder` class) are blocked. This prevents the start of the SDK without user consent. - -**Further reduced data collection:** None - -**Uses hooks:** Yes - -**Further details:** - -### InMobi - -**Purpose:** Ads - -**How consent is implemented:** If no consent is given, then `gdpr_consent_available=false` and `gdpr=1` is passed to the `init()` method of InMobi. +```java +ConsentManager consentManager = + new ConsentManager.Builder(this) + .setPrivacyPolicy(Uri.parse("https://example.com/privacy")) + .setShowConsent(true) + // Industry standard consent signals + .enableTcf() // IAB TCF v2.2 + .setGdprApplies(true) // Set based on user location + .setPublisherCountryCode("DE") // Your country (ISO 3166-1) + .enableUsPrivacy() // IAB US Privacy (CCPA) + .setCcpaApplies(true) // Set based on user location + .enableGpc() // Global Privacy Control + .build(); +``` -**Further reduced data collection:** None +### 4. Optional: GPC for WebViews -**Uses hooks:** Yes +If your app uses WebViews, wrap them with `GpcWebViewClient` to send the GPC signal to websites: -**Further details:** +```java +webView.setWebViewClient(new GpcWebViewClient()); +webView.getSettings().setJavaScriptEnabled(true); +``` -### AppsFlyer +This injects both the `Sec-GPC: 1` HTTP header and `navigator.globalPrivacyControl = true`. -**Purpose:** Ad attribution +### 5. GPC coverage across HTTP stacks -**How consent is implemented:** If no consent is given, calls to the `start()` method of AppsFlyer are prevented. +When `.enableGpc()` is called, the `Sec-GPC: 1` header is automatically injected across all major HTTP clients used by Android SDKs: -**Further reduced data collection:** None +| HTTP client | Coverage mechanism | SDKs using it | +|---|---|---| +| **OkHttp3** | Build-time bytecode transform on `Request.Builder.build()` | Most modern ad SDKs | +| **OkHttp2** | Build-time bytecode transform on `Request.Builder.build()` | Older SDKs | +| **Cronet** | Build-time bytecode transform on `UrlRequest.Builder.build()` | Google SDKs (Ads, Play Services) | +| **HttpURLConnection** | `URLStreamHandlerFactory` at runtime (standard Java API) | Firebase, some older SDKs | +| **WebViews** | `GpcWebViewClient` (header + `navigator.globalPrivacyControl`) | Any in-app web content | -**Uses hooks:** Yes +For your app's own HTTP requests, you can also use `GpcInterceptor.applyTo()` manually: -**Further details:** +```java +HttpURLConnection conn = (HttpURLConnection) url.openConnection(); +GpcInterceptor.applyTo(conn); // Adds Sec-GPC: 1 if GPC is enabled +``` -### ironSource +## Configuration Options -**Purpose:** Ads +| Builder method | Description | +|---|---| +| `.setPrivacyPolicy(Uri)` | **Required.** Link to your privacy policy | +| `.setShowConsent(boolean)` | Show the consent dialog (default: true) | +| `.setExcludedLibraries(String[])` | Exclude specific SDKs from consent management | +| `.setCustomLibraries(Library[])` | Add custom SDK handlers | +| `.enableTcf()` | Enable IAB TCF v2.2 signals | +| `.enableTcf(cmpId, version)` | Enable TCF with a registered CMP ID | +| `.setGdprApplies(boolean)` | Whether GDPR applies to this user | +| `.setPublisherCountryCode(String)` | Publisher's country (ISO 3166-1 alpha-2) | +| `.enableUsPrivacy()` | Enable IAB US Privacy (CCPA) string | +| `.setCcpaApplies(boolean)` | Whether CCPA applies to this user | +| `.enableGpc()` | Enable Global Privacy Control | -**How consent is implemented:** Depending on the consent setting, `setConsent` and the `do_not_sell` flags are set. +## How the Build-Time Transform Works -**Further reduced data collection:** Depending on the consent setting, the `is_deviceid_optout` flag is set. +The Gradle plugin uses the Android Gradle Plugin's `AsmClassVisitorFactory` to modify SDK bytecode during compilation. For each SDK method that needs consent gating, it injects a check at the beginning: -**Uses hooks:** No +```java +// Before (original SDK bytecode): +MobileAds.initialize(context) { + ... sdk code ... +} -**Further details:** +// After (transformed at build time): +MobileAds.initialize(context) { + if (!ConsentManager.getInstance().hasConsent("google_ads")) return; + ... sdk code ... +} +``` -### AdColony +This approach: +- Works on **all Android versions** (no ART runtime dependency) +- Causes **no Google Play Protect flags** (no method hooking) +- Has **zero runtime overhead** for the consent check wiring +- Is **deterministic** — the transform is applied at compile time, not at runtime -**Purpose:** Ads +### Adding a new SDK to the transform -**How consent is implemented:** Depending on the consent setting, `setPrivacyFrameworkRequired` and `setPrivacyConsentString` are called. This happens both at the time of initialising the SDK (i.e. on calling `AdColony.configure()`) and when the user might change the setting (by calling `AdColony.setAppOptions()`). Other `appOptions` should be kept intact in this process. +Add one line to `ConsentTransformRules.java`: -**Further reduced data collection:** None +```java +addRule("com/example/sdk/Tracker", "init", + "(Landroid/content/Context;)V", + "tracker_id", Action.BLOCK); +``` -**Uses hooks:** Yes +## Architecture -**Further details:** +``` +┌─ Build Time ──────────────────────────────────────────────────┐ +│ Gradle Plugin (ConsentTransformRules → ConsentMethodVisitor) │ +│ Injects consent checks into SDK bytecode │ +└───────────────────────────────────────────────────────────────┘ + +┌─ Runtime ─────────────────────────────────────────────────────┐ +│ ConsentManager │ +│ ├── Consent UI (AlertDialog with per-library opt-in) │ +│ ├── Consent storage (SharedPreferences) │ +│ ├── TCF v2.2 signals (IABTCF_* in default SharedPreferences) │ +│ ├── US Privacy string (IABUSPrivacy_String) │ +│ ├── GPC: WebViews (GpcWebViewClient) │ +│ ├── GPC: HttpURLConnection (GpcUrlHandler — Java API) │ +│ └── SDK-specific reflection (setEnabled, setConsent, etc.) │ +└───────────────────────────────────────────────────────────────┘ +``` -### Vungle +## Implementation Details -**Purpose:** Ads +### Industry Standard Signals -**How consent is implemented:** Consent is passed to the Vungle library through its `updateConsentStatus` setting, either setting this to `OPTED_IN` or `OPTED_OUT`. Additionally, the current consent signal is passed once the initialisation of the Vungle library is finished. +**IAB TCF v2.2**: Writes all standard `IABTCF_*` keys to the app's default SharedPreferences. Most ad SDKs (Google Ads, AppLovin, InMobi, ironSource, etc.) read these natively before making network requests, so no interception is needed. -**Further reduced data collection:** None +**IAB US Privacy (CCPA)**: Writes the `IABUSPrivacy_String` (e.g., `1YNN` for consent given, `1YYN` for opted out). Ad SDKs that support CCPA read this natively. -**Uses hooks:** Yes +**Global Privacy Control**: The `Sec-GPC: 1` header is injected across all HTTP stacks: OkHttp3/OkHttp2/Cronet via build-time bytecode transforms, HttpURLConnection via `URLStreamHandlerFactory`, and WebViews via `GpcWebViewClient`. For consent signaling to SDKs that read preferences before making requests, TCF/US Privacy are also used. -**Further details:** +### SDK-Specific Details -### Google Play Services Advertising Identifier Library +**Google Firebase Analytics**: Managed through `setAnalyticsCollectionEnabled`. Manifest flags disable `google_analytics_ssaid_collection_enabled` and `google_analytics_default_allow_ad_personalization_signals`. -**Purpose:** User identification +**Google Crashlytics**: Managed through `setCrashlyticsCollectionEnabled`. Manifest flag disables `firebase_crashlytics_collection_enabled`. -**How consent is implemented:** Calls to the `getAdvertisingIdInfo` method throw an `IOException` if no consent is provided. The use of the `IOException` is one of the exceptions of the method signature and should be caught by apps in any case. +**Google Ads**: Build-time transform blocks `MobileAds.initialize()` and `BaseAdView.loadAd()` without consent. Manifest flag sets `DELAY_APP_MEASUREMENT_INIT`. -**Further reduced data collection:** None +**Facebook SDK**: Managed through `setAutoInitEnabled` and `setAutoLogAppEventsEnabled`. Manifest flags disable `AutoInitEnabled` and `AutoLogAppEventsEnabled`. -**Uses hooks:** Yes +**Google Advertising ID**: Build-time transform makes `getAdvertisingIdInfo()` throw `IOException` without consent (one of the method's declared exceptions). -**Further details:** +*Note: The use of US-based services in the EU may be legally problematic even with consent, due to surveillance concerns. See [noyb.eu](https://noyb.eu/en/austrian-dsb-eu-us-data-transfers-google-analytics-illegal) for details.* ## Existing Consent Solutions for Mobile -Before developing our tool, we first studied what consent tools for mobile apps already existed out there. For this purpose, we searched Google and GitHub for the terms “cmp mobile app” and “consent mobile app”. - -| Name | Relies on IAB? | Native | Open Source | Free | Automatic set-up | -| ------------------------------------------------------------ | -------------- | ------------------- | ------------------------------------------------------------ | --------------------- | ---------------- | -| [GDPRConsent](https://www.google.com/url?q=https://github.com/DavidEdwards/GDPRConsent&sa=D&source=editors&ust=1667574042281619&usg=AOvVaw2SpfNCKKCFdFToqsW8gqgB) | No | Yes | Yes | Yes | No | -| [gdprsdk](https://www.google.com/url?q=https://github.com/gdprsdk/android-gdpr-library&sa=D&source=editors&ust=1667574042284526&usg=AOvVaw0bHudYSQnQnDJS4kBB40qb) | Yes | Yes | Yes | Yes | IAB | -| [OneTrust App Consent](https://www.google.com/url?q=https://www.onetrust.com/products/mobile-app-consent/&sa=D&source=editors&ust=1667574042287123&usg=AOvVaw2uaDrN9EuAPFmGht0O35UX) | ? | ? | No | No, no public pricing | | -| [Usercentrics App Consent](https://www.google.com/url?q=https://usercentrics.com/in-app-sdk/&sa=D&source=editors&ust=1667574042289796&usg=AOvVaw1mkWQ5xhK41Yeivr65CkaG) | Yes | Yes | [Yes](https://www.google.com/url?q=https://github.com/Usercentrics/react-native-sdk&sa=D&source=editors&ust=1667574042291220&usg=AOvVaw2cn5-dGdHymIuBvAwingM2) | No, €4+/month | IAB | -| [UniConsent](https://www.google.com/url?q=https://www.uniconsent.com/mobile&sa=D&source=editors&ust=1667574042292472&usg=AOvVaw3aT_kPSoSbxRPQxyOshzY8) | Yes | ? | No | No, £20+/month | IAB | -| [TrustArc Mobile App Consent](https://www.google.com/url?q=https://trustarc.com/mobile-app-consent/&sa=D&source=editors&ust=1667574042294597&usg=AOvVaw0p-y7eWp8hg3rgoBMZ5rna) | ? | ? | No | No | | -| [Choice by Kochava and Quantcast](https://www.google.com/url?q=https://www.kochava.com/consent-management-platform/&sa=D&source=editors&ust=1667574042296475&usg=AOvVaw1Op4etJUuMdRjkQi5BY9cu) | Yes | Yes | No | No | IAB | -| [Cookie Information](https://www.google.com/url?q=https://cookieinformation.com/mobile-app-consent/&sa=D&source=editors&ust=1667574042298486&usg=AOvVaw1TAPE8r7_MzSWVCYT6OV6J) | No | Yes | [Yes](https://www.google.com/url?q=https://bitbucket.org/cookieinformation/mobileconsents-android/src/master/&sa=D&source=editors&ust=1667574042299640&usg=AOvVaw26QWu1JvH0VqCTX-MPI_kh) | No, €125+/month | | -| [iubenda Consent Solution](https://www.google.com/url?q=https://www.iubenda.com/en/consent-solution&sa=D&source=editors&ust=1667574042300737&usg=AOvVaw2C0l-UvrupfGSupX3xI6Fy) | Yes | Yes, but not public | No | No, $10+/month | IAB | -| [Ogury Choice Manager](https://www.google.com/url?q=https://ogury-ltd.gitbook.io/choice-manager-android/&sa=D&source=editors&ust=1667574042302503&usg=AOvVaw18HzgDfWYxdpWQI_7aQltY) | Yes | Yes | No | No, no public pricing | IAB | -| [Tamoco Consent SDK](https://www.google.com/url?q=https://docs.tamoco.com/publishers/privacy/sdk&sa=D&source=editors&ust=1667574042304392&usg=AOvVaw13NDtIM0fdJZBGPfgu4bdl) | Yes | Yes | No | No, no public pricing | IAB | -| [CookiePro by OneTrust](https://www.google.com/url?q=https://www.cookiepro.com&sa=D&source=editors&ust=1667574042306287&usg=AOvVaw0PN2shy3FhRiFv1EiFPHcQ) | Yes | Yes | No | No, $200+/month | IAB | -| [consentmanager.net](https://www.google.com/url?q=https://www.consentmanager.net&sa=D&source=editors&ust=1667574042308028&usg=AOvVaw3XYQzEIF2Eh27pr4h85rN1) | Yes | Yes | No | No, €49+/month | IAB | -| [FreeCMP by TrueData](https://www.google.com/url?q=https://www.freecmp.com&sa=D&source=editors&ust=1667574042309911&usg=AOvVaw2D3_kl11zbHcIU5QI9w99x) | ? | Yes | No | No public pricing | | -| [Osano Consent Management](https://www.google.com/url?q=https://www.osano.com/products/consent-management&sa=D&source=editors&ust=1667574042311902&usg=AOvVaw2E2q9fI1E0-4tSENGVIxRr) | Yes | Yes, beta | No | No, £470+/month | IAB | -| [Didomi](https://www.google.com/url?q=https://developers.didomi.io/cmp/mobile-sdk/consent-notice/getting-started%23configure-your-app-name-and-logo&sa=D&source=editors&ust=1667574042313821&usg=AOvVaw1SzhwJIp5GGi5jyyK1Wl4U) | Yes | Yes | No | No, €300+/month | IAB | -| [SFBX CMP](https://www.google.com/url?q=https://sfbx.io/en/produits/&sa=D&source=editors&ust=1667574042316088&usg=AOvVaw29n7G__ZRWN_p2himWSsg2) | Yes | Yes | No | Yes, with limits | IAB | -| [Google Consent API (discontinued)](https://www.google.com/url?q=https://developers.google.com/admob/android/eu-consent&sa=D&source=editors&ust=1667574042318891&usg=AOvVaw0u6iVBNnpN-cDA-cwMzcJz) | No | Yes | Yes | Yes | No | -| [Google User Messaging Platform](https://www.google.com/url?q=https://developers.google.com/admob/ump/android/quick-start&sa=D&source=editors&ust=1667574042321288&usg=AOvVaw0R-Tcm7D_M7ujBMxhHiuSU) | Yes | Yes | No | Yes | IAB | - -Having studied these different tools, most of them did not seem to be aimed at mobile apps (and rather just ports from desktop) or were not mature yet. This motivated our subsequent development of “auto-app-consent”. +| Name | Open Source | Free | Automatic setup | +|---|---|---|---| +| [GDPRConsent](https://github.com/DavidEdwards/GDPRConsent) | Yes | Yes | No | +| [gdprsdk](https://github.com/gdprsdk/android-gdpr-library) | Yes | Yes | IAB only | +| [Google User Messaging Platform](https://developers.google.com/admob/ump/android/quick-start) | No | Yes | IAB only | +| [Usercentrics](https://usercentrics.com/in-app-sdk/) | Partial | No (€4+/mo) | IAB only | +| [OneTrust](https://www.onetrust.com/products/mobile-app-consent/) | No | No | Unknown | +| **This tool** | **Yes** | **Yes** | **Yes — automatic for 12 SDKs + TCF + CCPA + GPC** | ## Contribution -Contributions to this project are highly welcome. Help is welcome with testing, improving the stability of the existing code, keeping up with changes of the third-party libraries and contributing new adapters for third-party libraries. +Contributions are highly welcome: +- Testing across different SDK versions +- Adding transform rules for new SDKs +- Improving the consent UI -Feel free to file an issue or pull request with any of your ideas! +Feel free to file an issue or pull request. ## License diff --git a/app/build.gradle b/app/build.gradle index 4bbbf0f..7c6a95b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' + id 'net.kollnig.consent.plugin' } android { diff --git a/app/src/main/java/net/kollnig/consent/app/MainActivity.java b/app/src/main/java/net/kollnig/consent/app/MainActivity.java index 055fa46..3ee57d2 100644 --- a/app/src/main/java/net/kollnig/consent/app/MainActivity.java +++ b/app/src/main/java/net/kollnig/consent/app/MainActivity.java @@ -42,6 +42,14 @@ protected void onCreate(Bundle savedInstanceState) { new ConsentManager.Builder(this) .setShowConsent(true) .setPrivacyPolicy(Uri.parse("http://www.example.org/privacy")) + // Industry standard consent signals + .enableTcf() // IAB TCF v2.2 (with TC String) + .setGdprApplies(true) // GDPR applies to EU users + .setPublisherCountryCode("DE") // Publisher country + .enableUsPrivacy() // IAB US Privacy (CCPA) + .setCcpaApplies(true) // CCPA applies to CA users + .enableGpc() // Global Privacy Control + .enableGoogleConsentMode() // Google Consent Mode v2 //.setExcludedLibraries(new String[]{"firebase_analytics"}) //.setCustomLibraries(new Library[]{new CustomLibrary()}) .build(); @@ -52,6 +60,11 @@ protected void onCreate(Bundle savedInstanceState) { Log.d(TAG, "Detected and managed libraries: " + String.join(", ", consentManager.getManagedLibraries())); + // Log which standards are active + Log.d(TAG, "TCF enabled: " + (consentManager.getTcfManager() != null)); + Log.d(TAG, "US Privacy enabled: " + (consentManager.getUsPrivacyManager() != null)); + Log.d(TAG, "GPC enabled: " + consentManager.isGpcEnabled()); + // Initialise Firebase FirebaseAnalytics mFirebaseAnalytics = FirebaseAnalytics.getInstance(this); @@ -152,4 +165,4 @@ protected void onPause() { super.onPause(); IronSource.onPause(this); } -} \ No newline at end of file +} diff --git a/build.gradle b/build.gradle index f3c8904..51ae0a5 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,9 @@ buildscript { classpath 'com.google.gms:google-services:4.3.13' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.1' } -}// Top-level build file where you can add configuration options common to all sub-projects/modules. +} + +// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '7.2.1' apply false diff --git a/library/build.gradle b/library/build.gradle index 65535e2..eb47e1f 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -19,6 +19,9 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + testOptions { + unitTests.includeAndroidResources = true + } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -26,13 +29,11 @@ android { } dependencies { - implementation 'io.github.pagalaxylab:yahfa:0.10.0' - - //implementation "org.lsposed.lsplant:lsplant:4.1" - implementation 'androidx.appcompat:appcompat:1.4.2' implementation 'com.google.android.material:material:1.6.1' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.11.1' + testImplementation 'androidx.test:core:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } \ No newline at end of file diff --git a/library/src/main/java/net/kollnig/consent/ConsentManager.java b/library/src/main/java/net/kollnig/consent/ConsentManager.java index 0b9ea14..dd8feec 100644 --- a/library/src/main/java/net/kollnig/consent/ConsentManager.java +++ b/library/src/main/java/net/kollnig/consent/ConsentManager.java @@ -24,11 +24,18 @@ import net.kollnig.consent.library.Library; import net.kollnig.consent.library.LibraryInteractionException; import net.kollnig.consent.library.VungleLibrary; +import net.kollnig.consent.purpose.ConsentPurpose; +import net.kollnig.consent.standards.GoogleConsentMode; +import net.kollnig.consent.standards.GpcInterceptor; +import net.kollnig.consent.standards.GpcUrlHandler; +import net.kollnig.consent.standards.TcfConsentManager; +import net.kollnig.consent.standards.UsPrivacyManager; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.Set; public class ConsentManager { @@ -39,9 +46,18 @@ public class ConsentManager { private final Uri privacyPolicy; private final boolean showConsent; private List libraries; + private Map purposes; private final Context context; private final String[] excludedLibraries; + // Standards support + private TcfConsentManager tcfManager; + private UsPrivacyManager usPrivacyManager; + private boolean gpcEnabled; + private boolean gdprApplies; + private boolean ccpaApplies; + private boolean consentModeEnabled; + Library[] availableLibraries = { new FirebaseAnalyticsLibrary(), new CrashlyticsLibrary(), @@ -60,25 +76,61 @@ public class ConsentManager { private ConsentManager(Context context, boolean showConsent, Uri privacyPolicy, - String[] excludedLibraries) { + String[] excludedLibraries, + boolean enableTcf, + int tcfCmpSdkId, + int tcfCmpSdkVersion, + String publisherCountryCode, + boolean enableUsPrivacy, + boolean enableGpc, + boolean enableConsentMode, + boolean gdprApplies, + boolean ccpaApplies) { this.context = context; this.showConsent = showConsent; this.privacyPolicy = privacyPolicy; this.excludedLibraries = excludedLibraries; + this.gdprApplies = gdprApplies; + this.ccpaApplies = ccpaApplies; + this.gpcEnabled = enableGpc; + this.consentModeEnabled = enableConsentMode; + + if (enableTcf) { + this.tcfManager = new TcfConsentManager( + context, tcfCmpSdkId, tcfCmpSdkVersion, publisherCountryCode); + } + if (enableUsPrivacy) { + this.usPrivacyManager = new UsPrivacyManager(context); + } + if (enableGpc) { + GpcInterceptor.setEnabled(true); + GpcUrlHandler.install(); + } } private static ConsentManager getInstance(Context context, Boolean showConsent, Uri privacyPolicy, String[] excludeLibraries, - Library[] customLibraries) { + Library[] customLibraries, + boolean enableTcf, + int tcfCmpSdkId, + int tcfCmpSdkVersion, + String publisherCountryCode, + boolean enableUsPrivacy, + boolean enableGpc, + boolean enableConsentMode, + boolean gdprApplies, + boolean ccpaApplies) { if (mConsentManager == null) { - mConsentManager = new ConsentManager(context, showConsent, privacyPolicy, excludeLibraries); + mConsentManager = new ConsentManager( + context, showConsent, privacyPolicy, excludeLibraries, + enableTcf, tcfCmpSdkId, tcfCmpSdkVersion, publisherCountryCode, + enableUsPrivacy, enableGpc, enableConsentMode, gdprApplies, ccpaApplies); mConsentManager.libraries = new LinkedList<>(); try { - // merge `availableLibraries` and `customLibraries` into `allLibraries` List allLibraries = new LinkedList<>(Arrays.asList(mConsentManager.availableLibraries)); allLibraries.addAll(Arrays.asList(customLibraries)); @@ -88,13 +140,21 @@ private static ConsentManager getInstance(Context context, continue; library.initialise(context); - mConsentManager.libraries.add(library); } } catch (LibraryInteractionException e) { e.printStackTrace(); } + // Build purpose map, filtering to only purposes that have present libraries + mConsentManager.purposes = ConsentPurpose.getDefaults(); + + // Write initial deny signals + mConsentManager.updateStandardsSignals(false); + if (mConsentManager.consentModeEnabled) { + GoogleConsentMode.setConsent(context, false, false); + } + mConsentManager.askConsent(); } @@ -104,7 +164,6 @@ private static ConsentManager getInstance(Context context, public static ConsentManager getInstance() { if (mConsentManager == null) throw new RuntimeException("ConsentManager has not yet been correctly initialised."); - return mConsentManager; } @@ -112,10 +171,10 @@ private static SharedPreferences getPreferences(Context context) { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); } - public @Nullable - Boolean hasConsent(String libraryId) { - SharedPreferences prefs = getPreferences(context); + // ---- Per-library consent (used by build-time transforms) ---- + public @Nullable Boolean hasConsent(String libraryId) { + SharedPreferences prefs = getPreferences(context); Set set = prefs.getStringSet("consents", new HashSet<>()); if (set.contains(libraryId + ":" + true)) return true; @@ -125,95 +184,212 @@ else if (set.contains(libraryId + ":" + false)) return null; } + public void saveConsent(String libraryId, boolean consent) { + SharedPreferences prefs = getPreferences(context); + Set set = prefs.getStringSet("consents", null); + Set prefsSet = new HashSet<>(); + if (set != null) prefsSet.addAll(set); + + for (Library library : libraries) { + if (!library.getId().equals(libraryId)) continue; + try { + library.passConsentToLibrary(consent); + prefsSet.remove(library.getId() + ":" + true); + prefsSet.remove(library.getId() + ":" + false); + prefsSet.add(library.getId() + ":" + consent); + } catch (LibraryInteractionException e) { + e.printStackTrace(); + } + } + prefs.edit().putStringSet("consents", prefsSet).apply(); + } + + // ---- Purpose-based consent ---- + + /** + * Save consent for an entire purpose. This sets consent for all SDKs + * in that purpose and updates all standards signals. + */ + public void savePurposeConsent(String purposeId, boolean consent) { + ConsentPurpose purpose = purposes.get(purposeId); + if (purpose == null) return; + + // Save consent for each library in this purpose + for (String libraryId : purpose.libraryIds) { + saveConsent(libraryId, consent); + } + + // Save purpose-level consent + SharedPreferences prefs = getPreferences(context); + Set purposeSet = prefs.getStringSet("purpose_consents", null); + Set prefsSet = new HashSet<>(); + if (purposeSet != null) prefsSet.addAll(purposeSet); + prefsSet.remove(purposeId + ":true"); + prefsSet.remove(purposeId + ":false"); + prefsSet.add(purposeId + ":" + consent); + prefs.edit().putStringSet("purpose_consents", prefsSet).apply(); + + // Update all standards signals + updateAllSignals(); + } + + /** + * Check if a purpose has been consented to. + */ + public @Nullable Boolean hasPurposeConsent(String purposeId) { + SharedPreferences prefs = getPreferences(context); + Set set = prefs.getStringSet("purpose_consents", new HashSet<>()); + if (set.contains(purposeId + ":true")) return true; + else if (set.contains(purposeId + ":false")) return false; + else return null; + } + + /** + * Get the purpose definitions. + */ + public Map getPurposes() { + return purposes; + } + public String[] getManagedLibraries() { String[] libraryIds = new String[libraries.size()]; - for (int i = 0; i < libraries.size(); i++) { libraryIds[i] = libraries.get(i).getId(); } - return libraryIds; } public void clearConsent() { getPreferences(context).edit().clear().apply(); + if (tcfManager != null) tcfManager.clearConsentSignals(); + if (usPrivacyManager != null) usPrivacyManager.clearConsentSignal(); + GpcInterceptor.setEnabled(gpcEnabled); } - public void saveConsent(String libraryId, boolean consent) { - SharedPreferences prefs = getPreferences(context); - - Set set = prefs.getStringSet("consents", null); - Set prefsSet = new HashSet<>(); - if (set != null) - prefsSet.addAll(set); + // ---- Standards signals ---- + + private void updateAllSignals() { + boolean analyticsConsent = Boolean.TRUE.equals( + hasPurposeConsent(ConsentPurpose.PURPOSE_ANALYTICS)); + boolean adsConsent = Boolean.TRUE.equals( + hasPurposeConsent(ConsentPurpose.PURPOSE_ADVERTISING)); + boolean anyConsent = analyticsConsent || adsConsent + || Boolean.TRUE.equals(hasPurposeConsent(ConsentPurpose.PURPOSE_CRASH_REPORTING)) + || Boolean.TRUE.equals(hasPurposeConsent(ConsentPurpose.PURPOSE_SOCIAL)); + + // TCF: build per-purpose consent array from purpose decisions + if (tcfManager != null) { + boolean[] tcfPurposes = new boolean[TcfConsentManager.PURPOSE_COUNT]; + boolean[] tcfSpecialFeatures = new boolean[TcfConsentManager.SPECIAL_FEATURE_COUNT]; + + for (ConsentPurpose purpose : purposes.values()) { + boolean consented = Boolean.TRUE.equals(hasPurposeConsent(purpose.id)); + for (int tcfId : purpose.tcfPurposeIds) { + if (tcfId >= 1 && tcfId <= TcfConsentManager.PURPOSE_COUNT) { + tcfPurposes[tcfId - 1] = consented; + } + } + // Special case: identification maps to special feature 2 + if (ConsentPurpose.PURPOSE_IDENTIFICATION.equals(purpose.id)) { + tcfSpecialFeatures[1] = consented; // Special Feature 2 + } + } - for (Library library : libraries) { - if (!library.getId().equals(libraryId)) - continue; + tcfManager.writeConsentSignals(gdprApplies, tcfPurposes, tcfSpecialFeatures); + } - try { - library.passConsentToLibrary(consent); + // US Privacy + if (usPrivacyManager != null) { + usPrivacyManager.writeConsentSignal(ccpaApplies, anyConsent); + } - prefsSet.remove(library.getId() + ":" + true); - prefsSet.remove(library.getId() + ":" + false); + // Google Consent Mode v2 + if (consentModeEnabled) { + GoogleConsentMode.setConsent(context, analyticsConsent, adsConsent); + } + } - prefsSet.add(library.getId() + ":" + consent); - } catch (LibraryInteractionException e) { - e.printStackTrace(); - } + private void updateStandardsSignals(boolean consent) { + if (tcfManager != null) { + tcfManager.writeConsentSignals(gdprApplies, consent); + } + if (usPrivacyManager != null) { + usPrivacyManager.writeConsentSignal(ccpaApplies, consent); } - prefs.edit().putStringSet("consents", prefsSet).apply(); } + @Nullable public TcfConsentManager getTcfManager() { return tcfManager; } + @Nullable public UsPrivacyManager getUsPrivacyManager() { return usPrivacyManager; } + public boolean isGpcEnabled() { return GpcInterceptor.isEnabled(); } + + // ---- Consent dialog ---- + + /** + * Show the purpose-based consent dialog. + * Lists purposes (Analytics, Advertising, etc.) instead of individual SDKs. + */ public void askConsent() { - List ids = new LinkedList<>(); - List names = new LinkedList<>(); - List selectedItems = new LinkedList<>(); + // Build list of purposes that have at least one present library and need consent + List pendingPurposes = new LinkedList<>(); + Set presentLibraryIds = new HashSet<>(); for (Library library : libraries) { if (library.isPresent()) { - String libraryId = library.getId(); - if (hasConsent(libraryId) == null && showConsent) { - ids.add(libraryId); - names.add(context.getString(library.getName())); + presentLibraryIds.add(library.getId()); + } + } + + for (ConsentPurpose purpose : purposes.values()) { + if (purpose.essential) continue; + if (hasPurposeConsent(purpose.id) != null) continue; + if (!showConsent) continue; + + // Only show purposes that have at least one present library + boolean hasPresent = false; + for (String libId : purpose.libraryIds) { + if (presentLibraryIds.contains(libId)) { + hasPresent = true; + break; } } + if (hasPresent) { + pendingPurposes.add(purpose); + } } - if (ids.size() == 0) - return; + if (pendingPurposes.isEmpty()) return; - final AlertDialog alertDialog = new AlertDialog.Builder(context) - .setTitle(R.string.consent_title) - .setPositiveButton(R.string.accept_selected, (dialog, which) -> { - for (Library library : libraries) { - String libraryId = library.getId(); + String[] names = new String[pendingPurposes.size()]; + for (int i = 0; i < pendingPurposes.size(); i++) { + ConsentPurpose p = pendingPurposes.get(i); + names[i] = context.getString(p.nameResId) + "\n" + + context.getString(p.descriptionResId); + } - if (!ids.contains(libraryId)) - continue; + List selectedPurposes = new LinkedList<>(); - saveConsent(libraryId, selectedItems.contains(libraryId)); + final AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(R.string.consent_purpose_title) + .setPositiveButton(R.string.accept_selected, (dialog, which) -> { + for (ConsentPurpose purpose : pendingPurposes) { + savePurposeConsent(purpose.id, + selectedPurposes.contains(purpose.id)); } }) - /*.setNegativeButton(R.string.no, (dialog, which) -> { - for (Library library: libraries) { - String libraryId = library.getId(); - - if (!ids.contains(libraryId)) - continue; - - saveConsent(libraryId, false); + .setNegativeButton(R.string.reject_all, (dialog, which) -> { + for (ConsentPurpose purpose : pendingPurposes) { + savePurposeConsent(purpose.id, false); } - })*/ - .setMultiChoiceItems(names.toArray(new String[0]), null, (dialog, i, isChecked) -> { - if (isChecked) selectedItems.add(ids.get(i)); - else selectedItems.remove(ids.get(i)); + }) + .setMultiChoiceItems(names, null, (dialog, i, isChecked) -> { + String purposeId = pendingPurposes.get(i).id; + if (isChecked) selectedPurposes.add(purposeId); + else selectedPurposes.remove(purposeId); }) .setNeutralButton(R.string.privacy_policy, null) .setCancelable(false) .create(); - // this is needed to avoid the dialog from closing on clicking the policy button alertDialog.setOnShowListener(dialogInterface -> { Button neutralButton = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL); neutralButton.setOnClickListener(view -> { @@ -225,6 +401,8 @@ public void askConsent() { alertDialog.show(); } + // ---- Builder ---- + public static class Builder { Context context; boolean showConsent = true; @@ -232,31 +410,83 @@ public static class Builder { String[] excludedLibraries = {}; Library[] customLibraries = {}; + boolean enableTcf = false; + int tcfCmpSdkId = 0; + int tcfCmpSdkVersion = 1; + String publisherCountryCode = "AA"; + boolean enableUsPrivacy = false; + boolean enableGpc = false; + boolean enableConsentMode = false; + boolean gdprApplies = false; + boolean ccpaApplies = false; + public Builder(Context context) { this.context = context; } public Builder setCustomLibraries(Library[] customLibraries) { this.customLibraries = customLibraries; - return this; } public Builder setShowConsent(boolean showConsent) { this.showConsent = showConsent; - return this; } public Builder setPrivacyPolicy(Uri privacyPolicy) { this.privacyPolicy = privacyPolicy; - return this; } public Builder setExcludedLibraries(String[] excludedLibraries) { this.excludedLibraries = excludedLibraries; + return this; + } + + public Builder enableTcf(int cmpSdkId, int sdkVersion) { + this.enableTcf = true; + this.tcfCmpSdkId = cmpSdkId; + this.tcfCmpSdkVersion = sdkVersion; + return this; + } + + public Builder enableTcf() { + return enableTcf(0, 1); + } + + public Builder setPublisherCountryCode(String countryCode) { + this.publisherCountryCode = countryCode; + return this; + } + + public Builder enableUsPrivacy() { + this.enableUsPrivacy = true; + return this; + } + + public Builder enableGpc() { + this.enableGpc = true; + return this; + } + + /** + * Enable Google Consent Mode v2. Sets ad_storage, analytics_storage, + * ad_user_data, and ad_personalization signals that Google SDKs read. + * Required for EU ad serving since March 2024. + */ + public Builder enableGoogleConsentMode() { + this.enableConsentMode = true; + return this; + } + + public Builder setGdprApplies(boolean gdprApplies) { + this.gdprApplies = gdprApplies; + return this; + } + public Builder setCcpaApplies(boolean ccpaApplies) { + this.ccpaApplies = ccpaApplies; return this; } @@ -264,7 +494,10 @@ public ConsentManager build() { if (privacyPolicy == null) throw new RuntimeException("No privacy policy provided."); - return ConsentManager.getInstance(context, showConsent, privacyPolicy, excludedLibraries, customLibraries); + return ConsentManager.getInstance( + context, showConsent, privacyPolicy, excludedLibraries, customLibraries, + enableTcf, tcfCmpSdkId, tcfCmpSdkVersion, publisherCountryCode, + enableUsPrivacy, enableGpc, enableConsentMode, gdprApplies, ccpaApplies); } } } diff --git a/library/src/main/java/net/kollnig/consent/library/AdColonyLibrary.java b/library/src/main/java/net/kollnig/consent/library/AdColonyLibrary.java index 243439a..8041c15 100644 --- a/library/src/main/java/net/kollnig/consent/library/AdColonyLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/AdColonyLibrary.java @@ -1,11 +1,9 @@ package net.kollnig.consent.library; import android.content.Context; -import android.util.Log; import androidx.annotation.NonNull; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; import java.lang.reflect.InvocationTargetException; @@ -14,30 +12,23 @@ import java.util.LinkedList; import java.util.List; -import lab.galaxy.yahfa.HookMain; - public class AdColonyLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "adcolony"; - public static boolean replacementInit(Context var0, Object var1, @NonNull String var2) { - Log.d(TAG, "successfully hooked AdColony"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) { - var1 = getAppOptions(var1, false); - } - - return originalInit(var0, var1, var2); + @NonNull + @Override + public String getId() { + return LIBRARY_IDENTIFIER; } - static final String TAG = "HOOKED"; + @NonNull + private static Class getOptionsClass() throws ClassNotFoundException { + return Class.forName("com.adcolony.sdk.AdColonyAppOptions"); + } private @NonNull static Object getAppOptions(Object options, boolean consent) { - //AdColonyAppOptions options = new AdColonyAppOptions() - // .setPrivacyFrameworkRequired(AdColonyAppOptions.GDPR, true) - // .setPrivacyConsentString(AdColonyAppOptions.GDPR, consent); try { - // AdColony.getAppOptions(); if (options == null) { Class baseClass = Class.forName("com.adcolony.sdk.AdColony"); Method getAppOptions = baseClass.getMethod("getAppOptions"); @@ -59,91 +50,17 @@ static Object getAppOptions(Object options, boolean consent) { setPrivacyConsentString.invoke(options, GDPR, consentString); return options; - } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException | NoSuchFieldException e) { + } catch (Exception e) { throw new RuntimeException("Failed to interact with AdColony SDK."); } } - // this method will be replaced by hook - public static boolean originalInit(Context var0, Object var1, @NonNull String var2) { - throw new RuntimeException("Could not overwrite original AdColony method"); - } - - @NonNull - @Override - public String getId() { - return LIBRARY_IDENTIFIER; - } - - @NonNull - private static Class getOptionsClass() throws ClassNotFoundException { - return Class.forName("com.adcolony.sdk.AdColonyAppOptions"); - } - - // AdColony.configure() - // a(Context var0, AdColonyAppOptions var1, @NonNull String var2) - private static Method findInitMethod() throws LibraryInteractionException { - List matchingMethods = new LinkedList<>(); - try { - Class baseClass = Class.forName("com.adcolony.sdk.AdColony"); - - for (Method method : baseClass.getDeclaredMethods()) { - Modifier.isStatic(method.getModifiers()); - - if (!method.getReturnType().equals(boolean.class)) - continue; - - Class[] parameterTypes = method.getParameterTypes(); - if (parameterTypes.length != 3) - continue; - - if (!parameterTypes[0].equals(Context.class) - || !parameterTypes[1].equals(getOptionsClass()) - || !parameterTypes[2].equals(String.class)) - continue; - - matchingMethods.add(method); - } - } catch (ClassNotFoundException e) { - throw new LibraryInteractionException("Could not find target class."); - } - - if (matchingMethods.size() > 1) - throw new LibraryInteractionException("Could not determine target method."); - - return matchingMethods.get(0); - } - - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - // AdColony.configure() - // a(Context var0, AdColonyAppOptions var1, @NonNull String var2) - // a(Landroid/content/Context;Lcom/adcolony/sdk/AdColonyAppOptions;Ljava/lang/String;)Z - Class baseClass = findBaseClass(); - String methodName = findInitMethod().getName(); - String methodSig = "(Landroid/content/Context;Lcom/adcolony/sdk/AdColonyAppOptions;Ljava/lang/String;)Z"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = AdColonyLibrary.class.getMethod("replacementInit", Context.class, Object.class, String.class); - Method methodBackup = AdColonyLibrary.class.getMethod("originalInit", Context.class, Object.class, String.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - - return this; - } - @Override public void passConsentToLibrary(boolean consent) throws LibraryInteractionException { Class baseClass = findBaseClass(); try { Object options = getAppOptions(null, consent); - // AdColony.setAppOptions(); Method setAppOptions = baseClass.getMethod("setAppOptions", options.getClass()); setAppOptions.invoke(null, options); } catch (NoSuchMethodException diff --git a/library/src/main/java/net/kollnig/consent/library/AdvertisingIdLibrary.java b/library/src/main/java/net/kollnig/consent/library/AdvertisingIdLibrary.java index 33d858c..00130ff 100644 --- a/library/src/main/java/net/kollnig/consent/library/AdvertisingIdLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/AdvertisingIdLibrary.java @@ -1,18 +1,11 @@ package net.kollnig.consent.library; import android.content.Context; -import android.util.Log; import androidx.annotation.NonNull; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; -import java.io.IOException; -import java.lang.reflect.Method; - -import lab.galaxy.yahfa.HookMain; - public class AdvertisingIdLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "google_ads_identifier"; @@ -22,45 +15,10 @@ public String getId() { return LIBRARY_IDENTIFIER; } - static final String TAG = "HOOKED"; - - public static Object replacementMethod(@NonNull Context context) throws IOException { - Log.d(TAG, "successfully hooked AAID"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) - throw new IOException("Blocked attempt to access Advertising Identifier without consent."); - - return originalMethod(context); - } - - // this method will be replaced by hook - public static Object originalMethod(@NonNull Context context) { - throw new RuntimeException("Could not overwrite original Firebase method"); - } - - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - Class advertisingIdClass = findBaseClass(); - String methodName = "getAdvertisingIdInfo"; - String methodSig = "(Landroid/content/Context;)Lcom/google/android/gms/ads/identifier/AdvertisingIdClient$Info;"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(advertisingIdClass, methodName, methodSig); - Method methodHook = AdvertisingIdLibrary.class.getMethod("replacementMethod", Context.class); - Method methodBackup = AdvertisingIdLibrary.class.getMethod("originalMethod", Context.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - - return this; - } - @Override public void passConsentToLibrary(boolean consent) { - // do nothing + // Consent enforcement handled by build-time bytecode transform + // (throws IOException if no consent) or TCF SharedPreferences signals } @Override diff --git a/library/src/main/java/net/kollnig/consent/library/AppsFlyerLibrary.java b/library/src/main/java/net/kollnig/consent/library/AppsFlyerLibrary.java index 1306fe5..78d7ca2 100644 --- a/library/src/main/java/net/kollnig/consent/library/AppsFlyerLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/AppsFlyerLibrary.java @@ -1,18 +1,14 @@ package net.kollnig.consent.library; import android.content.Context; -import android.util.Log; import androidx.annotation.NonNull; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import lab.galaxy.yahfa.HookMain; - public class AppsFlyerLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "appsflyer"; @@ -22,51 +18,6 @@ public String getId() { return LIBRARY_IDENTIFIER; } - static final String TAG = "HOOKED"; - - public static void replacementStart(Object thiz, Context context, String string, Object object) { - Log.d(TAG, "successfully hooked AppsFlyer"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) - return; - - originalStart(thiz, context, string, object); - } - - // this method will be replaced by hook - public static void originalStart(Object thiz, Context context, String string, Object object) { - throw new RuntimeException("Could not overwrite original AppsFlyer method"); - } - - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - // AppsFlyerLib.getInstance().start(this); - try { - Class abstractBaseClass = findBaseClass(); - Method getInstance = abstractBaseClass.getMethod("getInstance"); - Object instance = getInstance.invoke(null); - - Class baseClass = instance.getClass(); - String methodName = "start"; - String methodSig = "(Landroid/content/Context;Ljava/lang/String;Lcom/appsflyer/attribution/AppsFlyerRequestListener;)V"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = AppsFlyerLibrary.class.getMethod("replacementStart", Object.class, Context.class, String.class, Object.class); - Method methodBackup = AppsFlyerLibrary.class.getMethod("originalStart", Object.class, Context.class, String.class, Object.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { - e.printStackTrace(); - } - - return this; - } - @Override public void passConsentToLibrary(boolean consent) { try { diff --git a/library/src/main/java/net/kollnig/consent/library/FlurryLibrary.java b/library/src/main/java/net/kollnig/consent/library/FlurryLibrary.java index d837422..0e85b85 100644 --- a/library/src/main/java/net/kollnig/consent/library/FlurryLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/FlurryLibrary.java @@ -1,17 +1,9 @@ package net.kollnig.consent.library; -import android.content.Context; -import android.util.Log; - import androidx.annotation.NonNull; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; -import java.lang.reflect.Method; - -import lab.galaxy.yahfa.HookMain; - public class FlurryLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "flurry"; @@ -21,54 +13,10 @@ public String getId() { return LIBRARY_IDENTIFIER; } - static final String TAG = "HOOKED"; - - public static void replacementBuild(Object thiz, @NonNull Context var1, @NonNull String var2) { - Log.d(TAG, "successfully hooked Flurry"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) { - /*try { - Method withDataSaleOptOut = thiz.getClass().getMethod("withDataSaleOptOut", boolean.class); - withDataSaleOptOut.invoke(thiz, true); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - e.printStackTrace(); - }*/ - - return; - } - - originalBuild(thiz, var1, var2); - } - - // this method will be replaced by hook - public static void originalBuild(Object thiz, @NonNull Context var1, @NonNull String var2) { - throw new RuntimeException("Could not overwrite original Flurry method"); - } - - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - // build(Landroid/content/Context;Ljava/lang/String;)V - Class baseClass = findBaseClass(); - String methodName = "build"; - String methodSig = "(Landroid/content/Context;Ljava/lang/String;)V"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = FlurryLibrary.class.getMethod("replacementBuild", Object.class, Context.class, String.class); - Method methodBackup = FlurryLibrary.class.getMethod("originalBuild", Object.class, Context.class, String.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - - return this; - } - @Override public void passConsentToLibrary(boolean consent) { - // do nothing + // Consent enforcement handled by build-time bytecode transform + // (blocks build() if no consent) or TCF SharedPreferences signals } @Override diff --git a/library/src/main/java/net/kollnig/consent/library/GoogleAdsLibrary.java b/library/src/main/java/net/kollnig/consent/library/GoogleAdsLibrary.java index 320f851..4b81fef 100644 --- a/library/src/main/java/net/kollnig/consent/library/GoogleAdsLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/GoogleAdsLibrary.java @@ -1,17 +1,11 @@ package net.kollnig.consent.library; import android.content.Context; -import android.util.Log; import androidx.annotation.NonNull; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; -import java.lang.reflect.Method; - -import lab.galaxy.yahfa.HookMain; - public class GoogleAdsLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "google_ads"; @@ -21,96 +15,6 @@ public String getId() { return LIBRARY_IDENTIFIER; } - static final String TAG = "HOOKED"; - - public static void replacementMethod(@NonNull Context context) { - Log.d(TAG, "successfully hooked GAds"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) - return; - - originalMethod(context); - } - - // this method will be replaced by hook - public static void originalMethod(@NonNull Context context) { - throw new RuntimeException("Could not overwrite original Firebase method"); - } - - public static void replacementMethod(@NonNull Context context, @NonNull Object listener) { - Log.d(TAG, "successfully hooked GAds"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) - return; - - originalMethod(context, listener); - } - - // this method will be replaced by hook - public static void originalMethod(@NonNull Context context, @NonNull Object listener) { - throw new RuntimeException("Could not overwrite original Firebase method"); - } - - public static void replacementLoadAd(Object thiz, @NonNull Object adRequest) { - Log.d(TAG, "successfully hooked GAds"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) - return; - - originalLoadAd(thiz, adRequest); - } - - // this method will be replaced by hook - public static void originalLoadAd(Object thiz, @NonNull Object adRequest) { - throw new RuntimeException("Could not overwrite original Firebase method"); - } - - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - // public void loadAd(@NonNull AdRequest adRequest) { - try { - Class baseClass = Class.forName("com.google.android.gms.ads.BaseAdView"); - String methodName = "loadAd"; - String methodSig = "(Lcom/google/android/gms/ads/AdRequest;)V"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = GoogleAdsLibrary.class.getMethod("replacementLoadAd", Object.class, Object.class); - Method methodBackup = GoogleAdsLibrary.class.getMethod("originalLoadAd", Object.class, Object.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - } catch (ClassNotFoundException e) { - e.printStackTrace(); - } - - //.method public static initialize(Landroid/content/Context;)V - //.method public static initialize(Landroid/content/Context;Lcom/google/android/gms/ads/initialization/OnInitializationCompleteListener;)V - Class baseClass = findBaseClass(); - String methodName = "initialize"; - String methodSig = "(Landroid/content/Context;)V"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = GoogleAdsLibrary.class.getMethod("replacementMethod", Context.class); - Method methodBackup = GoogleAdsLibrary.class.getMethod("originalMethod", Context.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - - String methodSig2 = "(Landroid/content/Context;Lcom/google/android/gms/ads/initialization/OnInitializationCompleteListener;)V"; - Method methodOrig2 = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig2); - Method methodHook2 = GoogleAdsLibrary.class.getMethod("replacementMethod", Context.class, Object.class); - Method methodBackup2 = GoogleAdsLibrary.class.getMethod("originalMethod", Context.class, Object.class); - HookMain.backupAndHook(methodOrig2, methodHook2, methodBackup2); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - - return this; - } - @Override public void passConsentToLibrary(boolean consent) { if (!consent) diff --git a/library/src/main/java/net/kollnig/consent/library/InMobiLibrary.java b/library/src/main/java/net/kollnig/consent/library/InMobiLibrary.java index b83d710..ba8db41 100644 --- a/library/src/main/java/net/kollnig/consent/library/InMobiLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/InMobiLibrary.java @@ -1,80 +1,22 @@ package net.kollnig.consent.library; -import android.content.Context; -import android.util.Log; - import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Size; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; -import org.json.JSONException; -import org.json.JSONObject; - -import java.lang.reflect.Method; - -import lab.galaxy.yahfa.HookMain; - public class InMobiLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "inmobi"; - public static void replacementInit(@NonNull final Context var0, @NonNull @Size(min = 32L, max = 36L) final String var1, @Nullable JSONObject var2, @Nullable final Object var3) { - Log.d(TAG, "successfully hooked Inmobi"); - - if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER))) { - if (var2 == null) - var2 = new JSONObject(); - - try { - var2.put("gdpr_consent_available", false); - var2.put("gdpr", "1"); - } catch (JSONException e) { - e.printStackTrace(); - } - } - - originalInit(var0, var1, var2, var3); - } - - static final String TAG = "HOOKED"; - @NonNull @Override public String getId() { return LIBRARY_IDENTIFIER; } - // this method will be replaced by hook - public static void originalInit(@NonNull final Context var0, @NonNull @Size(min = 32L, max = 36L) final String var1, @Nullable JSONObject var2, @Nullable final Object var3) { - throw new RuntimeException("Could not overwrite original Inmobi method"); - } - - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - // InMobiSdk.init(this, "Insert InMobi Account ID here", consentObject, new SdkInitializationListener() - Class baseClass = findBaseClass(); - String methodName = "init"; - String methodSig = "(Landroid/content/Context;Ljava/lang/String;Lorg/json/JSONObject;Lcom/inmobi/sdk/SdkInitializationListener;)V"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = InMobiLibrary.class.getMethod("replacementInit", Context.class, String.class, JSONObject.class, Object.class); - Method methodBackup = InMobiLibrary.class.getMethod("originalInit", Context.class, String.class, JSONObject.class, Object.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - - return this; - } - @Override public void passConsentToLibrary(boolean consent) { - // do nothing + // Consent enforcement handled by build-time bytecode transform + // (blocks init() if no consent) or TCF SharedPreferences signals } @Override diff --git a/library/src/main/java/net/kollnig/consent/library/VungleLibrary.java b/library/src/main/java/net/kollnig/consent/library/VungleLibrary.java index 81f605e..0c91f2a 100644 --- a/library/src/main/java/net/kollnig/consent/library/VungleLibrary.java +++ b/library/src/main/java/net/kollnig/consent/library/VungleLibrary.java @@ -1,19 +1,13 @@ package net.kollnig.consent.library; -import android.content.Context; -import android.util.Log; - import androidx.annotation.NonNull; -import net.kollnig.consent.ConsentManager; import net.kollnig.consent.R; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Objects; -import lab.galaxy.yahfa.HookMain; - public class VungleLibrary extends Library { public static final String LIBRARY_IDENTIFIER = "vungle"; @@ -23,27 +17,8 @@ String getId() { return LIBRARY_IDENTIFIER; } - static final String TAG = "HOOKED"; - - public static void replacementInit(Object thiz, @NonNull final Object callback, boolean isReconfig) { - Log.d(TAG, "successfully hooked Vungle"); - - originalInit(thiz, callback, isReconfig); - - boolean consent = Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent(LIBRARY_IDENTIFIER)); - try { - passConsent(consent); - } catch (LibraryInteractionException e) { - throw new RuntimeException("Passing consent to Vungle failed."); - } - } - - // this method will be replaced by hook - public static void originalInit(Object thiz, @NonNull final Object callback, boolean isReconfig) { - throw new RuntimeException("Could not overwrite original Vungle method"); - } - - private static void passConsent(boolean consent) throws LibraryInteractionException { + @Override + public void passConsentToLibrary(boolean consent) throws LibraryInteractionException { try { Class baseClass = Class.forName("com.vungle.warren.Vungle"); Class consentClass = Class.forName("com.vungle.warren.Vungle$Consent"); @@ -62,7 +37,6 @@ private static void passConsent(boolean consent) throws LibraryInteractionExcept if (optIn == null || optOut == null) throw new LibraryInteractionException("Could not retrieve consent objects."); - // public static void updateConsentStatus(@NonNull Consent status, @NonNull String consentMessageVersion) Method updateConsentStatus = baseClass.getMethod("updateConsentStatus", consentClass, String.class); updateConsentStatus.invoke(null, consent ? optIn : optOut, "1.0.0"); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | ClassNotFoundException e) { @@ -70,32 +44,6 @@ private static void passConsent(boolean consent) throws LibraryInteractionExcept } } - @Override - public Library initialise(Context context) throws LibraryInteractionException { - super.initialise(context); - - // private void configure(@NonNull final InitCallback callback, boolean isReconfig) { - Class baseClass = findBaseClass(); - String methodName = "configure"; - String methodSig = "(Lcom/vungle/warren/InitCallback;Z)V"; - - try { - Method methodOrig = (Method) HookMain.findMethodNative(baseClass, methodName, methodSig); - Method methodHook = VungleLibrary.class.getMethod("replacementInit", Object.class, Object.class, boolean.class); - Method methodBackup = VungleLibrary.class.getMethod("originalInit", Object.class, Object.class, boolean.class); - HookMain.backupAndHook(methodOrig, methodHook, methodBackup); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Could not overwrite method"); - } - - return this; - } - - @Override - public void passConsentToLibrary(boolean consent) throws LibraryInteractionException { - passConsent(consent); - } - @Override String getBaseClass() { return "com.vungle.warren.Vungle"; diff --git a/library/src/main/java/net/kollnig/consent/purpose/ConsentPurpose.java b/library/src/main/java/net/kollnig/consent/purpose/ConsentPurpose.java new file mode 100644 index 0000000..9290cd6 --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/purpose/ConsentPurpose.java @@ -0,0 +1,111 @@ +package net.kollnig.consent.purpose; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Defines consent purposes (categories) and maps SDKs to them. + * + * Under GDPR/TCF, consent is organized by PURPOSE, not by vendor. + * Users consent to "Analytics" or "Advertising", not to "InMobi" or "AppsFlyer". + * + * Each purpose maps to one or more TCF purpose IDs and one or more SDK library IDs. + * When a user grants/denies consent to a purpose, all associated SDKs are affected. + */ +public class ConsentPurpose { + + /** Unique identifier for this purpose. */ + public final String id; + + /** String resource ID for the display name (e.g. "Analytics"). */ + public final int nameResId; + + /** String resource ID for the description shown in the consent dialog. */ + public final int descriptionResId; + + /** TCF v2.2 purpose IDs associated with this purpose (1-indexed). */ + public final int[] tcfPurposeIds; + + /** SDK library IDs that belong to this purpose. */ + public final List libraryIds; + + /** Whether this purpose is strictly necessary (no consent needed). */ + public final boolean essential; + + public ConsentPurpose(String id, int nameResId, int descriptionResId, + int[] tcfPurposeIds, List libraryIds, boolean essential) { + this.id = id; + this.nameResId = nameResId; + this.descriptionResId = descriptionResId; + this.tcfPurposeIds = tcfPurposeIds; + this.libraryIds = Collections.unmodifiableList(libraryIds); + this.essential = essential; + } + + /** + * Returns the default purpose definitions with SDK mappings. + * Ordered as they should appear in the consent dialog. + */ + public static Map getDefaults() { + // Use LinkedHashMap to preserve insertion order for dialog display + Map purposes = new LinkedHashMap<>(); + + purposes.put(PURPOSE_ANALYTICS, new ConsentPurpose( + PURPOSE_ANALYTICS, + net.kollnig.consent.R.string.purpose_analytics_name, + net.kollnig.consent.R.string.purpose_analytics_desc, + new int[]{7, 8, 9}, // Measure ad perf, content perf, market research + Arrays.asList("firebase_analytics", "flurry", "appsflyer"), + false + )); + + purposes.put(PURPOSE_ADVERTISING, new ConsentPurpose( + PURPOSE_ADVERTISING, + net.kollnig.consent.R.string.purpose_advertising_name, + net.kollnig.consent.R.string.purpose_advertising_desc, + new int[]{2, 3, 4}, // Basic ads, personalized ads profile, select personalized ads + Arrays.asList("google_ads", "applovin", "inmobi", "adcolony", + "ironsource", "vungle"), + false + )); + + purposes.put(PURPOSE_CRASH_REPORTING, new ConsentPurpose( + PURPOSE_CRASH_REPORTING, + net.kollnig.consent.R.string.purpose_crash_reporting_name, + net.kollnig.consent.R.string.purpose_crash_reporting_desc, + new int[]{10}, // Develop and improve products + Arrays.asList("crashlytics"), + false + )); + + purposes.put(PURPOSE_SOCIAL, new ConsentPurpose( + PURPOSE_SOCIAL, + net.kollnig.consent.R.string.purpose_social_name, + net.kollnig.consent.R.string.purpose_social_desc, + new int[]{1}, // Store and/or access information on a device + Arrays.asList("facebook_sdk"), + false + )); + + purposes.put(PURPOSE_IDENTIFICATION, new ConsentPurpose( + PURPOSE_IDENTIFICATION, + net.kollnig.consent.R.string.purpose_identification_name, + net.kollnig.consent.R.string.purpose_identification_desc, + new int[]{}, // Maps to Special Feature 2 (device scanning), not a purpose + Arrays.asList("google_ads_identifier"), + false + )); + + return purposes; + } + + // Purpose ID constants + public static final String PURPOSE_ANALYTICS = "analytics"; + public static final String PURPOSE_ADVERTISING = "advertising"; + public static final String PURPOSE_CRASH_REPORTING = "crash_reporting"; + public static final String PURPOSE_SOCIAL = "social"; + public static final String PURPOSE_IDENTIFICATION = "identification"; +} diff --git a/library/src/main/java/net/kollnig/consent/standards/GoogleConsentMode.java b/library/src/main/java/net/kollnig/consent/standards/GoogleConsentMode.java new file mode 100644 index 0000000..d847900 --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/GoogleConsentMode.java @@ -0,0 +1,120 @@ +package net.kollnig.consent.standards; + +import android.content.Context; +import android.util.Log; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * Google Consent Mode v2 integration. + * + * Sets consent signals that Google Ads and Firebase Analytics read to decide + * how to behave when consent is denied. Required for EU ad serving since + * March 2024. + * + * Consent types: + * - ANALYTICS_STORAGE: Enables storage for analytics (e.g. cookies, app identifiers) + * - AD_STORAGE: Enables storage for advertising + * - AD_USER_DATA: Consent to send user data to Google for advertising + * - AD_PERSONALIZATION: Consent to use data for personalized advertising + * + * When denied, Google SDKs fall back to "cookieless pings" and modeled + * conversions instead of refusing to work entirely. + * + * Uses reflection to call FirebaseAnalytics.setConsent() so there's no + * compile-time dependency on Firebase. + */ +public class GoogleConsentMode { + + private static final String TAG = "GoogleConsentMode"; + + // Consent type enum values (from com.google.firebase.analytics.FirebaseAnalytics.ConsentType) + private static final String CONSENT_TYPE_CLASS = + "com.google.firebase.analytics.FirebaseAnalytics$ConsentType"; + private static final String CONSENT_STATUS_CLASS = + "com.google.firebase.analytics.FirebaseAnalytics$ConsentStatus"; + + /** + * Set Google Consent Mode v2 signals based on per-purpose consent. + * + * @param context Android context + * @param analyticsConsent Whether analytics purpose was consented to + * @param adsConsent Whether advertising purpose was consented to + */ + public static void setConsent(Context context, + boolean analyticsConsent, + boolean adsConsent) { + try { + // Load the enum classes + Class consentTypeClass = Class.forName(CONSENT_TYPE_CLASS); + Class consentStatusClass = Class.forName(CONSENT_STATUS_CLASS); + + // Get enum values + Object analyticsStorage = getEnumValue(consentTypeClass, "ANALYTICS_STORAGE"); + Object adStorage = getEnumValue(consentTypeClass, "AD_STORAGE"); + Object adUserData = getEnumValue(consentTypeClass, "AD_USER_DATA"); + Object adPersonalization = getEnumValue(consentTypeClass, "AD_PERSONALIZATION"); + + Object granted = getEnumValue(consentStatusClass, "GRANTED"); + Object denied = getEnumValue(consentStatusClass, "DENIED"); + + if (analyticsStorage == null || adStorage == null || + adUserData == null || adPersonalization == null || + granted == null || denied == null) { + Log.w(TAG, "Could not resolve Consent Mode enum values " + + "(Firebase Analytics may not support Consent Mode v2)"); + return; + } + + // Build the consent map + @SuppressWarnings({"unchecked", "rawtypes"}) + Map consentMap = new HashMap(); + consentMap.put(analyticsStorage, analyticsConsent ? granted : denied); + consentMap.put(adStorage, adsConsent ? granted : denied); + consentMap.put(adUserData, adsConsent ? granted : denied); + consentMap.put(adPersonalization, adsConsent ? granted : denied); + + // Call FirebaseAnalytics.getInstance(context).setConsent(consentMap) + Class firebaseClass = Class.forName( + "com.google.firebase.analytics.FirebaseAnalytics"); + Method getInstance = firebaseClass.getMethod("getInstance", Context.class); + Object analytics = getInstance.invoke(null, context); + + Method setConsent = firebaseClass.getMethod("setConsent", Map.class); + setConsent.invoke(analytics, consentMap); + + Log.d(TAG, "Google Consent Mode v2 set: analytics=" + + analyticsConsent + ", ads=" + adsConsent); + + } catch (ClassNotFoundException e) { + // Firebase Analytics not present — that's fine + Log.d(TAG, "Firebase Analytics not present, skipping Consent Mode v2"); + } catch (Exception e) { + Log.w(TAG, "Failed to set Google Consent Mode v2: " + e.getMessage()); + } + } + + /** + * Check if Google Consent Mode v2 is available (Firebase Analytics present + * and has the ConsentType enum). + */ + public static boolean isAvailable() { + try { + Class.forName(CONSENT_TYPE_CLASS); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + private static Object getEnumValue(Class enumClass, String name) { + try { + Method valueOf = enumClass.getMethod("valueOf", String.class); + return valueOf.invoke(null, name); + } catch (Exception e) { + return null; + } + } +} diff --git a/library/src/main/java/net/kollnig/consent/standards/GpcInterceptor.java b/library/src/main/java/net/kollnig/consent/standards/GpcInterceptor.java new file mode 100644 index 0000000..0018c1b --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/GpcInterceptor.java @@ -0,0 +1,66 @@ +package net.kollnig.consent.standards; + +import java.net.HttpURLConnection; + +/** + * Global Privacy Control (GPC) signal management. + * + * GPC is a web standard (Sec-GPC: 1 header + navigator.globalPrivacyControl) + * that tells websites the user does not want their personal data sold or shared. + * It is legally recognized under CCPA, GDPR, and the Colorado Privacy Act. + * + * IMPORTANT: GPC is a *browser/web* standard. It is designed for: + * - WebViews loading web content (use GpcWebViewClient) + * - The app's own HTTP requests to its backend (use applyTo()) + * + * GPC is NOT designed for in-app SDK traffic. Ad/analytics SDKs use their own + * consent mechanisms: + * - IAB TCF v2.2 strings in SharedPreferences (see TcfConsentManager) + * - IAB US Privacy strings in SharedPreferences (see UsPrivacyManager) + * - SDK-specific consent APIs (what the Library classes handle) + * + * Injecting GPC headers into SDK HTTPS traffic is not feasible because: + * - Most SDKs use HTTPS with certificate pinning + * - No proxy/MITM approach can modify pinned HTTPS headers + * - ART method hooking (YAHFA) is fragile and Android-version-limited + * + * See: https://globalprivacycontrol.github.io/gpc-spec/ + */ +public class GpcInterceptor { + + public static final String GPC_HEADER_NAME = "Sec-GPC"; + public static final String GPC_HEADER_VALUE = "1"; + + private static volatile boolean enabled = false; + + /** + * Enable or disable GPC globally. + * When enabled, GpcWebViewClient will inject GPC signals into WebViews, + * and applyTo() will add the header to HttpURLConnections. + */ + public static void setEnabled(boolean gpcEnabled) { + enabled = gpcEnabled; + } + + /** + * Returns whether GPC is currently enabled. + */ + public static boolean isEnabled() { + return enabled; + } + + /** + * Apply the GPC header to an HttpURLConnection. + * Use this for the app's own HTTP requests to its backend server. + * + * Example: + * HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + * GpcInterceptor.applyTo(conn); + * conn.connect(); + */ + public static void applyTo(HttpURLConnection connection) { + if (enabled) { + connection.setRequestProperty(GPC_HEADER_NAME, GPC_HEADER_VALUE); + } + } +} diff --git a/library/src/main/java/net/kollnig/consent/standards/GpcUrlHandler.java b/library/src/main/java/net/kollnig/consent/standards/GpcUrlHandler.java new file mode 100644 index 0000000..d911957 --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/GpcUrlHandler.java @@ -0,0 +1,148 @@ +package net.kollnig.consent.standards; + +import android.util.Log; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.lang.reflect.Constructor; + +/** + * Injects Sec-GPC: 1 into HttpURLConnection requests using Java's + * built-in URLStreamHandlerFactory API. + * + * Coverage note: This only affects connections created via + * URL.openConnection(). Most modern ad SDKs use OkHttp or Cronet + * internally (which bypass this), so those are covered by the + * build-time bytecode transform plugin instead. + * + * Limitation: URL.setURLStreamHandlerFactory() can only be called ONCE + * per JVM. If another library has already set it, we log a warning. + */ +public class GpcUrlHandler { + + private static final String TAG = "GpcUrlHandler"; + private static boolean installed = false; + private static URLStreamHandler defaultHttpHandler; + private static URLStreamHandler defaultHttpsHandler; + + /** + * Install the GPC-aware URLStreamHandlerFactory. + * Safe to call multiple times — only installs once. + */ + public static synchronized void install() { + if (installed) return; + + // Resolve the platform's default handlers BEFORE setting our factory. + // On Android, these are com.android.okhttp.HttpHandler and HttpsHandler. + defaultHttpHandler = getDefaultHandler("http"); + defaultHttpsHandler = getDefaultHandler("https"); + + if (defaultHttpHandler == null || defaultHttpsHandler == null) { + Log.w(TAG, "Could not resolve default HTTP handlers, skipping GPC factory"); + return; + } + + try { + URL.setURLStreamHandlerFactory(new GpcStreamHandlerFactory()); + installed = true; + Log.d(TAG, "GPC URLStreamHandlerFactory installed"); + } catch (Error e) { + Log.w(TAG, "Could not install GPC URLStreamHandlerFactory " + + "(another factory already set): " + e.getMessage()); + } + } + + public static boolean isInstalled() { + return installed; + } + + /** + * Get the platform's default URLStreamHandler for a protocol by creating + * a URL before our factory is installed. The handler is cached in the URL + * class and we can extract it. + */ + private static URLStreamHandler getDefaultHandler(String protocol) { + try { + // Creating a URL triggers handler resolution via the default mechanism. + // We do this BEFORE setting our factory, so it returns the platform handler. + URL testUrl = new URL(protocol + "://example.com"); + // Use reflection to read the handler field from the URL + java.lang.reflect.Field handlerField = URL.class.getDeclaredField("handler"); + handlerField.setAccessible(true); + return (URLStreamHandler) handlerField.get(testUrl); + } catch (Exception e) { + Log.w(TAG, "Could not get default " + protocol + " handler: " + e.getMessage()); + return null; + } + } + + private static class GpcStreamHandlerFactory implements URLStreamHandlerFactory { + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if ("http".equals(protocol)) { + return new GpcStreamHandler(defaultHttpHandler); + } + if ("https".equals(protocol)) { + return new GpcStreamHandler(defaultHttpsHandler); + } + return null; + } + } + + /** + * URLStreamHandler that delegates to the platform's default handler + * and adds the GPC header to HttpURLConnection results. + */ + private static class GpcStreamHandler extends URLStreamHandler { + private final URLStreamHandler delegate; + + GpcStreamHandler(URLStreamHandler delegate) { + this.delegate = delegate; + } + + @Override + protected URLConnection openConnection(URL url) throws IOException { + // Delegate to the platform's default handler + try { + java.lang.reflect.Method openConn = URLStreamHandler.class + .getDeclaredMethod("openConnection", URL.class); + openConn.setAccessible(true); + URLConnection conn = (URLConnection) openConn.invoke(delegate, url); + return addGpcHeader(conn); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("GPC handler delegation failed", e); + } + } + + @Override + protected URLConnection openConnection(URL url, Proxy proxy) throws IOException { + try { + java.lang.reflect.Method openConn = URLStreamHandler.class + .getDeclaredMethod("openConnection", URL.class, Proxy.class); + openConn.setAccessible(true); + URLConnection conn = (URLConnection) openConn.invoke(delegate, url, proxy); + return addGpcHeader(conn); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("GPC handler delegation failed", e); + } + } + + private URLConnection addGpcHeader(URLConnection conn) { + if (GpcInterceptor.isEnabled() && conn instanceof HttpURLConnection) { + conn.setRequestProperty( + GpcInterceptor.GPC_HEADER_NAME, + GpcInterceptor.GPC_HEADER_VALUE); + } + return conn; + } + } +} diff --git a/library/src/main/java/net/kollnig/consent/standards/GpcWebViewClient.java b/library/src/main/java/net/kollnig/consent/standards/GpcWebViewClient.java new file mode 100644 index 0000000..b13de8f --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/GpcWebViewClient.java @@ -0,0 +1,52 @@ +package net.kollnig.consent.standards; + +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import java.util.HashMap; +import java.util.Map; + +/** + * WebViewClient that injects Global Privacy Control signals into WebViews. + * + * This does two things: + * 1. Adds the Sec-GPC: 1 HTTP header to all WebView requests + * 2. Injects navigator.globalPrivacyControl = true via JavaScript + * + * Usage: + * webView.setWebViewClient(new GpcWebViewClient()); + * // or wrap an existing client: + * webView.setWebViewClient(new GpcWebViewClient(existingClient)); + * + * Make sure JavaScript is enabled on the WebView: + * webView.getSettings().setJavaScriptEnabled(true); + */ +public class GpcWebViewClient extends WebViewClient { + + private static final String GPC_JS_INJECTION = + "javascript:Object.defineProperty(navigator,'globalPrivacyControl'," + + "{value:true,writable:false,configurable:false});"; + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (GpcInterceptor.isEnabled()) { + Map headers = new HashMap<>(); + headers.put(GpcInterceptor.GPC_HEADER_NAME, GpcInterceptor.GPC_HEADER_VALUE); + view.loadUrl(request.getUrl().toString(), headers); + return true; + } + return super.shouldOverrideUrlLoading(view, request); + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + if (GpcInterceptor.isEnabled()) { + view.evaluateJavascript( + "Object.defineProperty(navigator,'globalPrivacyControl'," + + "{value:true,writable:false,configurable:false});", + null); + } + } +} diff --git a/library/src/main/java/net/kollnig/consent/standards/TcStringEncoder.java b/library/src/main/java/net/kollnig/consent/standards/TcStringEncoder.java new file mode 100644 index 0000000..1a6de7e --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/TcStringEncoder.java @@ -0,0 +1,180 @@ +package net.kollnig.consent.standards; + +import android.util.Base64; + +/** + * Encodes IAB TCF v2.2 TC Strings (Transparency & Consent strings). + * + * The TC String is a base64url-encoded binary format that encodes the full + * consent record: CMP info, purpose consents, vendor consents, etc. + * Many SDKs (notably Google Ads) check IABTCF_TCString before reading + * the individual IABTCF_PurposeConsents fields. + * + * Format reference: + * https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/ + * blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md + */ +public class TcStringEncoder { + + // TCF epoch: 2020-01-01T00:00:00Z in milliseconds + private static final long TCF_EPOCH_MS = 1577836800000L; + + private final BitWriter bits = new BitWriter(); + + /** + * Encode a TC String for a blanket consent/deny decision. + * + * @param cmpId CMP SDK ID (0 = unregistered) + * @param cmpVersion CMP SDK version + * @param consentLanguage Two-letter ISO 639-1 language code (e.g. "EN") + * @param publisherCC Two-letter ISO 3166-1 publisher country code + * @param purposeConsents boolean[24] — purposes 1-24 (true = consent) + * @param purposeLI boolean[24] — purposes 1-24 legitimate interest + * @param specialFeatures boolean[12] — special features 1-12 + * @param vendorListVersion Version of the GVL used (0 if none) + * @return Base64url-encoded TC String (core segment only) + */ + public static String encode(int cmpId, int cmpVersion, + String consentLanguage, String publisherCC, + boolean[] purposeConsents, boolean[] purposeLI, + boolean[] specialFeatures, + int vendorListVersion) { + + BitWriter bw = new BitWriter(); + + long now = System.currentTimeMillis(); + long deciseconds = (now - TCF_EPOCH_MS) / 100; + + // Core segment + bw.writeInt(2, 6); // Version = 2 + bw.writeLong(deciseconds, 36); // Created + bw.writeLong(deciseconds, 36); // LastUpdated + bw.writeInt(cmpId, 12); // CmpId + bw.writeInt(cmpVersion, 12); // CmpVersion + bw.writeInt(1, 6); // ConsentScreen (1 = first screen) + bw.writeLanguage(consentLanguage); // ConsentLanguage (12 bits) + bw.writeInt(vendorListVersion, 12); // VendorListVersion + bw.writeInt(4, 6); // TcfPolicyVersion = 4 (v2.2) + bw.writeBit(true); // IsServiceSpecific = true + bw.writeBit(false); // UseNonStandardTexts = false + + // SpecialFeatureOptIns — 12 bits + for (int i = 0; i < 12; i++) { + bw.writeBit(i < specialFeatures.length && specialFeatures[i]); + } + + // PurposesConsent — 24 bits + for (int i = 0; i < 24; i++) { + bw.writeBit(i < purposeConsents.length && purposeConsents[i]); + } + + // PurposesLITransparency — 24 bits + for (int i = 0; i < 24; i++) { + bw.writeBit(i < purposeLI.length && purposeLI[i]); + } + + // PurposeOneTreatment — 1 bit + bw.writeBit(false); + + // PublisherCC — 12 bits + bw.writeLanguage(publisherCC); + + // Vendor consent section — empty (no specific vendor consents) + bw.writeInt(0, 16); // MaxVendorConsentId = 0 + bw.writeBit(false); // IsRangeEncoding = false (bitfield, but empty) + + // Vendor legitimate interest section — empty + bw.writeInt(0, 16); // MaxVendorLIId = 0 + bw.writeBit(false); // IsRangeEncoding = false + + // Publisher restrictions — 0 restrictions + bw.writeInt(0, 12); // NumPubRestrictions = 0 + + return bw.toBase64Url(); + } + + /** + * Convenience: encode a blanket consent/deny for all purposes. + */ + public static String encode(int cmpId, int cmpVersion, + String consentLanguage, String publisherCC, + boolean consent) { + boolean[] purposes = new boolean[24]; + boolean[] purposesLI = new boolean[24]; + boolean[] specialFeatures = new boolean[12]; + + if (consent) { + // Consent to all 11 TCF purposes (indices 0-10) + for (int i = 0; i < TcfConsentManager.PURPOSE_COUNT; i++) { + purposes[i] = true; + purposesLI[i] = true; + } + // Opt in to both special features + for (int i = 0; i < TcfConsentManager.SPECIAL_FEATURE_COUNT; i++) { + specialFeatures[i] = true; + } + } + + return encode(cmpId, cmpVersion, consentLanguage, publisherCC, + purposes, purposesLI, specialFeatures, 0); + } + + /** + * Bit-level writer that accumulates bits and encodes to base64url. + */ + static class BitWriter { + private final StringBuilder bits = new StringBuilder(); + + void writeBit(boolean value) { + bits.append(value ? '1' : '0'); + } + + void writeInt(int value, int numBits) { + for (int i = numBits - 1; i >= 0; i--) { + bits.append(((value >> i) & 1) == 1 ? '1' : '0'); + } + } + + void writeLong(long value, int numBits) { + for (int i = numBits - 1; i >= 0; i--) { + bits.append(((value >> i) & 1) == 1 ? '1' : '0'); + } + } + + /** + * Write a two-letter code as 12 bits (two 6-bit values, A=0 ... Z=25). + */ + void writeLanguage(String twoLetterCode) { + if (twoLetterCode == null || twoLetterCode.length() != 2) { + writeInt(0, 12); + return; + } + String upper = twoLetterCode.toUpperCase(); + writeInt(upper.charAt(0) - 'A', 6); + writeInt(upper.charAt(1) - 'A', 6); + } + + /** + * Convert the accumulated bits to a base64url string (no padding). + */ + String toBase64Url() { + // Pad to multiple of 8 bits + while (bits.length() % 8 != 0) { + bits.append('0'); + } + + byte[] bytes = new byte[bits.length() / 8]; + for (int i = 0; i < bytes.length; i++) { + int b = 0; + for (int j = 0; j < 8; j++) { + b = (b << 1) | (bits.charAt(i * 8 + j) == '1' ? 1 : 0); + } + bytes[i] = (byte) b; + } + + // Base64url encoding (no padding, URL-safe) + return Base64.encodeToString(bytes, + Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP); + } + } +} diff --git a/library/src/main/java/net/kollnig/consent/standards/TcfConsentManager.java b/library/src/main/java/net/kollnig/consent/standards/TcfConsentManager.java new file mode 100644 index 0000000..d06df6f --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/TcfConsentManager.java @@ -0,0 +1,262 @@ +package net.kollnig.consent.standards; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * Manages IAB Transparency & Consent Framework (TCF) v2.2 consent signals. + * + * Most major ad SDKs (Google Ads, AppLovin, InMobi, ironSource, AdColony, Vungle, etc.) + * natively read TCF consent from SharedPreferences using the IABTCF_ prefix keys. + * By writing these keys, we can signal consent/denial without invasive method hooking. + * + * See: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework + */ +public class TcfConsentManager { + + // IAB TCF v2.2 SharedPreferences keys + private static final String IABTCF_CMP_SDK_ID = "IABTCF_CmpSdkID"; + private static final String IABTCF_CMP_SDK_VERSION = "IABTCF_CmpSdkVersion"; + private static final String IABTCF_POLICY_VERSION = "IABTCF_PolicyVersion"; + private static final String IABTCF_GDPR_APPLIES = "IABTCF_gdprApplies"; + private static final String IABTCF_PUBLISHER_CC = "IABTCF_PublisherCC"; + private static final String IABTCF_PURPOSE_ONE_TREATMENT = "IABTCF_PurposeOneTreatment"; + private static final String IABTCF_USE_NON_STANDARD_TEXTS = "IABTCF_UseNonStandardTexts"; + private static final String IABTCF_TC_STRING = "IABTCF_TCString"; + private static final String IABTCF_VENDOR_CONSENTS = "IABTCF_VendorConsents"; + private static final String IABTCF_VENDOR_LEGITIMATE_INTERESTS = "IABTCF_VendorLegitimateInterests"; + private static final String IABTCF_PURPOSE_CONSENTS = "IABTCF_PurposeConsents"; + private static final String IABTCF_PURPOSE_LEGITIMATE_INTERESTS = "IABTCF_PurposeLegitimateInterests"; + private static final String IABTCF_SPECIAL_FEATURES_OPT_INS = "IABTCF_SpecialFeaturesOptIns"; + private static final String IABTCF_PUBLISHER_CONSENT = "IABTCF_PublisherConsent"; + private static final String IABTCF_PUBLISHER_LEGITIMATE_INTERESTS = "IABTCF_PublisherLegitimateInterests"; + private static final String IABTCF_PUBLISHER_CUSTOM_PURPOSES_CONSENTS = "IABTCF_PublisherCustomPurposesConsents"; + private static final String IABTCF_PUBLISHER_CUSTOM_PURPOSES_LEGITIMATE_INTERESTS = "IABTCF_PublisherCustomPurposesLegitimateInterests"; + private static final String IABTCF_PUBLISHER_RESTRICTIONS = "IABTCF_PublisherRestrictions"; + + // TCF v2.2 Purpose IDs (1-11) + // 1: Store and/or access information on a device + // 2: Select basic ads + // 3: Create a personalised ads profile + // 4: Select personalised ads + // 5: Create a personalised content profile + // 6: Select personalised content + // 7: Measure ad performance + // 8: Measure content performance + // 9: Apply market research to generate audience insights + // 10: Develop and improve products + // 11: Use limited data to select content + public static final int PURPOSE_COUNT = 11; + + // TCF v2.2 Special Feature IDs (1-2) + // 1: Use precise geolocation data + // 2: Actively scan device characteristics for identification + public static final int SPECIAL_FEATURE_COUNT = 2; + + // CMP SDK ID — registered CMPs get an ID from IAB; 0 = not registered + // Apps using this library should register with IAB to get a real CMP ID. + private final int cmpSdkId; + private final int cmpSdkVersion; + private final String publisherCountryCode; + private final String consentLanguage; + + private final Context context; + + public TcfConsentManager(Context context, int cmpSdkId, int cmpSdkVersion, + String publisherCountryCode) { + this(context, cmpSdkId, cmpSdkVersion, publisherCountryCode, "EN"); + } + + public TcfConsentManager(Context context, int cmpSdkId, int cmpSdkVersion, + String publisherCountryCode, String consentLanguage) { + this.context = context; + this.cmpSdkId = cmpSdkId; + this.cmpSdkVersion = cmpSdkVersion; + this.publisherCountryCode = publisherCountryCode; + this.consentLanguage = consentLanguage; + } + + /** + * Returns the default SharedPreferences used by the IAB TCF spec. + * Per the spec, TCF keys MUST be stored in the app's default SharedPreferences. + */ + private SharedPreferences getDefaultPreferences() { + return android.preference.PreferenceManager.getDefaultSharedPreferences(context); + } + + /** + * Write TCF v2.2 consent signals based on a global consent/deny decision. + * + * @param gdprApplies whether GDPR applies to this user + * @param consent whether the user has given consent + */ + public void writeConsentSignals(boolean gdprApplies, boolean consent) { + SharedPreferences.Editor editor = getDefaultPreferences().edit(); + + // CMP identification + editor.putInt(IABTCF_CMP_SDK_ID, cmpSdkId); + editor.putInt(IABTCF_CMP_SDK_VERSION, cmpSdkVersion); + editor.putInt(IABTCF_POLICY_VERSION, 4); // TCF v2.2 policy version + + // GDPR applicability: 0 = no, 1 = yes + editor.putInt(IABTCF_GDPR_APPLIES, gdprApplies ? 1 : 0); + + // Publisher info + editor.putString(IABTCF_PUBLISHER_CC, publisherCountryCode); + editor.putInt(IABTCF_PURPOSE_ONE_TREATMENT, 0); // 0 = no special treatment + editor.putInt(IABTCF_USE_NON_STANDARD_TEXTS, 0); // 0 = standard texts + + // Purpose consents — binary string, one bit per purpose (1-indexed) + String purposeConsents = buildBinaryString(PURPOSE_COUNT, consent); + editor.putString(IABTCF_PURPOSE_CONSENTS, purposeConsents); + + // Purpose legitimate interests + editor.putString(IABTCF_PURPOSE_LEGITIMATE_INTERESTS, buildBinaryString(PURPOSE_COUNT, consent)); + + // Special features opt-ins + editor.putString(IABTCF_SPECIAL_FEATURES_OPT_INS, buildBinaryString(SPECIAL_FEATURE_COUNT, consent)); + + // Vendor consents and legitimate interests — empty if no consent + // These are populated per-vendor; for a blanket signal, set all or none + editor.putString(IABTCF_VENDOR_CONSENTS, ""); + editor.putString(IABTCF_VENDOR_LEGITIMATE_INTERESTS, ""); + + // Publisher consent/legitimate interests + editor.putString(IABTCF_PUBLISHER_CONSENT, buildBinaryString(PURPOSE_COUNT, consent)); + editor.putString(IABTCF_PUBLISHER_LEGITIMATE_INTERESTS, buildBinaryString(PURPOSE_COUNT, consent)); + editor.putString(IABTCF_PUBLISHER_CUSTOM_PURPOSES_CONSENTS, ""); + editor.putString(IABTCF_PUBLISHER_CUSTOM_PURPOSES_LEGITIMATE_INTERESTS, ""); + + // TC String — the encoded consent record that many SDKs check first + String tcString = TcStringEncoder.encode( + cmpSdkId, cmpSdkVersion, consentLanguage, publisherCountryCode, consent); + editor.putString(IABTCF_TC_STRING, tcString); + + editor.apply(); + } + + /** + * Write TCF v2.2 consent signals with per-purpose granularity. + * + * @param gdprApplies whether GDPR applies + * @param purposeConsents boolean array of length PURPOSE_COUNT (index 0 = purpose 1) + * @param specialFeatures boolean array of length SPECIAL_FEATURE_COUNT + */ + public void writeConsentSignals(boolean gdprApplies, + boolean[] purposeConsents, + boolean[] specialFeatures) { + if (purposeConsents.length != PURPOSE_COUNT) { + throw new IllegalArgumentException( + "purposeConsents must have " + PURPOSE_COUNT + " elements"); + } + if (specialFeatures.length != SPECIAL_FEATURE_COUNT) { + throw new IllegalArgumentException( + "specialFeatures must have " + SPECIAL_FEATURE_COUNT + " elements"); + } + + SharedPreferences.Editor editor = getDefaultPreferences().edit(); + + editor.putInt(IABTCF_CMP_SDK_ID, cmpSdkId); + editor.putInt(IABTCF_CMP_SDK_VERSION, cmpSdkVersion); + editor.putInt(IABTCF_POLICY_VERSION, 4); + editor.putInt(IABTCF_GDPR_APPLIES, gdprApplies ? 1 : 0); + editor.putString(IABTCF_PUBLISHER_CC, publisherCountryCode); + editor.putInt(IABTCF_PURPOSE_ONE_TREATMENT, 0); + editor.putInt(IABTCF_USE_NON_STANDARD_TEXTS, 0); + + editor.putString(IABTCF_PURPOSE_CONSENTS, buildBinaryString(purposeConsents)); + editor.putString(IABTCF_PURPOSE_LEGITIMATE_INTERESTS, buildBinaryString(purposeConsents)); + editor.putString(IABTCF_SPECIAL_FEATURES_OPT_INS, buildBinaryString(specialFeatures)); + + editor.putString(IABTCF_VENDOR_CONSENTS, ""); + editor.putString(IABTCF_VENDOR_LEGITIMATE_INTERESTS, ""); + editor.putString(IABTCF_PUBLISHER_CONSENT, buildBinaryString(purposeConsents)); + editor.putString(IABTCF_PUBLISHER_LEGITIMATE_INTERESTS, buildBinaryString(purposeConsents)); + editor.putString(IABTCF_PUBLISHER_CUSTOM_PURPOSES_CONSENTS, ""); + editor.putString(IABTCF_PUBLISHER_CUSTOM_PURPOSES_LEGITIMATE_INTERESTS, ""); + + // TC String — pad arrays to 24 and 12 as required by the spec + boolean[] purposes24 = new boolean[24]; + boolean[] specialFeatures12 = new boolean[12]; + System.arraycopy(purposeConsents, 0, purposes24, 0, purposeConsents.length); + System.arraycopy(specialFeatures, 0, specialFeatures12, 0, specialFeatures.length); + + String tcString = TcStringEncoder.encode( + cmpSdkId, cmpSdkVersion, consentLanguage, publisherCountryCode, + purposes24, purposes24, specialFeatures12, 0); + editor.putString(IABTCF_TC_STRING, tcString); + + editor.apply(); + } + + /** + * Clear all TCF signals from SharedPreferences. + */ + public void clearConsentSignals() { + SharedPreferences.Editor editor = getDefaultPreferences().edit(); + + editor.remove(IABTCF_CMP_SDK_ID); + editor.remove(IABTCF_CMP_SDK_VERSION); + editor.remove(IABTCF_POLICY_VERSION); + editor.remove(IABTCF_GDPR_APPLIES); + editor.remove(IABTCF_PUBLISHER_CC); + editor.remove(IABTCF_PURPOSE_ONE_TREATMENT); + editor.remove(IABTCF_USE_NON_STANDARD_TEXTS); + editor.remove(IABTCF_TC_STRING); + editor.remove(IABTCF_VENDOR_CONSENTS); + editor.remove(IABTCF_VENDOR_LEGITIMATE_INTERESTS); + editor.remove(IABTCF_PURPOSE_CONSENTS); + editor.remove(IABTCF_PURPOSE_LEGITIMATE_INTERESTS); + editor.remove(IABTCF_SPECIAL_FEATURES_OPT_INS); + editor.remove(IABTCF_PUBLISHER_CONSENT); + editor.remove(IABTCF_PUBLISHER_LEGITIMATE_INTERESTS); + editor.remove(IABTCF_PUBLISHER_CUSTOM_PURPOSES_CONSENTS); + editor.remove(IABTCF_PUBLISHER_CUSTOM_PURPOSES_LEGITIMATE_INTERESTS); + editor.remove(IABTCF_PUBLISHER_RESTRICTIONS); + + editor.apply(); + } + + /** + * Check if TCF signals have been written. + */ + public boolean hasConsentSignals() { + return getDefaultPreferences().contains(IABTCF_PURPOSE_CONSENTS); + } + + /** + * Read the current purpose consents as a boolean array. + */ + public boolean[] getPurposeConsents() { + String binary = getDefaultPreferences().getString(IABTCF_PURPOSE_CONSENTS, ""); + boolean[] result = new boolean[PURPOSE_COUNT]; + for (int i = 0; i < Math.min(binary.length(), PURPOSE_COUNT); i++) { + result[i] = binary.charAt(i) == '1'; + } + return result; + } + + /** + * Build a binary string of the given length, all set to the given value. + * E.g. buildBinaryString(3, true) = "111", buildBinaryString(3, false) = "000" + */ + private static String buildBinaryString(int length, boolean value) { + char c = value ? '1' : '0'; + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(c); + } + return sb.toString(); + } + + /** + * Build a binary string from a boolean array. + */ + private static String buildBinaryString(boolean[] values) { + StringBuilder sb = new StringBuilder(values.length); + for (boolean v : values) { + sb.append(v ? '1' : '0'); + } + return sb.toString(); + } +} diff --git a/library/src/main/java/net/kollnig/consent/standards/UsPrivacyManager.java b/library/src/main/java/net/kollnig/consent/standards/UsPrivacyManager.java new file mode 100644 index 0000000..f4d9713 --- /dev/null +++ b/library/src/main/java/net/kollnig/consent/standards/UsPrivacyManager.java @@ -0,0 +1,81 @@ +package net.kollnig.consent.standards; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * Manages IAB US Privacy String (CCPA) signals. + * + * The US Privacy String is a 4-character string stored in SharedPreferences + * under the key "IABUSPrivacy_String". Many ad SDKs read this natively. + * + * Format: [Version][Notice][OptOut][LSPA] + * - Version: 1 (current) + * - Notice: Y = notice given, N = not given, - = N/A + * - OptOut: Y = opted out of sale, N = not opted out, - = N/A + * - LSPA: Y = LSPA covered, N = not covered, - = N/A + * + * See: https://github.com/InteractiveAdvertisingBureau/USPrivacy + */ +public class UsPrivacyManager { + + private static final String IAB_US_PRIVACY_STRING = "IABUSPrivacy_String"; + private static final int VERSION = 1; + + private final Context context; + + public UsPrivacyManager(Context context) { + this.context = context; + } + + private SharedPreferences getDefaultPreferences() { + return android.preference.PreferenceManager.getDefaultSharedPreferences(context); + } + + /** + * Write the US Privacy (CCPA) string. + * + * @param ccpaApplies whether CCPA applies to this user + * @param consent whether the user has NOT opted out of sale (true = allow sale) + */ + public void writeConsentSignal(boolean ccpaApplies, boolean consent) { + String privacyString; + if (!ccpaApplies) { + // CCPA does not apply + privacyString = VERSION + "---"; + } else { + // Notice given, opt-out status based on consent, LSPA not covered + char optOut = consent ? 'N' : 'Y'; + privacyString = VERSION + "Y" + optOut + "N"; + } + + getDefaultPreferences().edit() + .putString(IAB_US_PRIVACY_STRING, privacyString) + .apply(); + } + + /** + * Clear the US Privacy string. + */ + public void clearConsentSignal() { + getDefaultPreferences().edit() + .remove(IAB_US_PRIVACY_STRING) + .apply(); + } + + /** + * Read the current US Privacy string. + */ + public String getPrivacyString() { + return getDefaultPreferences().getString(IAB_US_PRIVACY_STRING, null); + } + + /** + * Check if user has opted out of sale under CCPA. + */ + public boolean hasOptedOutOfSale() { + String privacyString = getPrivacyString(); + return privacyString != null && privacyString.length() >= 3 + && privacyString.charAt(2) == 'Y'; + } +} diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml index 1557bc5..e87f465 100644 --- a/library/src/main/res/values/strings.xml +++ b/library/src/main/res/values/strings.xml @@ -35,4 +35,21 @@ Privacy Policy Accept Reject all + + + This app uses the following data processing categories. Please select which you consent to: + Analytics + Understand how you use the app and measure performance. Data may be shared with analytics providers. + Advertising + Show you ads, including personalized ads based on your interests. Data may be shared with advertising networks. + Crash Reporting + Detect and fix app crashes to improve stability. Crash data may be shared with the developer\'s crash reporting service. + Social Features + Enable social media integration and features. Data may be shared with the social media platform. + Cross-App Tracking + Use an advertising identifier that can track your activity across apps. + + + This app respects your privacy choices through industry standards including IAB TCF v2.2, CCPA/US Privacy, and Global Privacy Control (GPC). + Global Privacy Control is active. A \"do not sell or share\" signal is sent with your web requests. diff --git a/library/src/test/java/net/kollnig/consent/standards/GpcInterceptorTest.java b/library/src/test/java/net/kollnig/consent/standards/GpcInterceptorTest.java new file mode 100644 index 0000000..4c40c6a --- /dev/null +++ b/library/src/test/java/net/kollnig/consent/standards/GpcInterceptorTest.java @@ -0,0 +1,72 @@ +package net.kollnig.consent.standards; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.net.HttpURLConnection; +import java.net.URL; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 30) +public class GpcInterceptorTest { + + @Before + public void setUp() { + GpcInterceptor.setEnabled(false); + } + + @After + public void tearDown() { + GpcInterceptor.setEnabled(false); + } + + @Test + public void disabledByDefault() { + assertFalse(GpcInterceptor.isEnabled()); + } + + @Test + public void enableAndDisable() { + GpcInterceptor.setEnabled(true); + assertTrue(GpcInterceptor.isEnabled()); + + GpcInterceptor.setEnabled(false); + assertFalse(GpcInterceptor.isEnabled()); + } + + @Test + public void headerConstants() { + assertEquals("Sec-GPC", GpcInterceptor.GPC_HEADER_NAME); + assertEquals("1", GpcInterceptor.GPC_HEADER_VALUE); + } + + @Test + public void applyTo_addsHeaderWhenEnabled() throws Exception { + GpcInterceptor.setEnabled(true); + + URL url = new URL("http://example.com"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + GpcInterceptor.applyTo(conn); + + assertEquals("1", conn.getRequestProperty("Sec-GPC")); + conn.disconnect(); + } + + @Test + public void applyTo_doesNotAddHeaderWhenDisabled() throws Exception { + GpcInterceptor.setEnabled(false); + + URL url = new URL("http://example.com"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + GpcInterceptor.applyTo(conn); + + assertNull(conn.getRequestProperty("Sec-GPC")); + conn.disconnect(); + } +} diff --git a/library/src/test/java/net/kollnig/consent/standards/TcStringEncoderTest.java b/library/src/test/java/net/kollnig/consent/standards/TcStringEncoderTest.java new file mode 100644 index 0000000..7207431 --- /dev/null +++ b/library/src/test/java/net/kollnig/consent/standards/TcStringEncoderTest.java @@ -0,0 +1,131 @@ +package net.kollnig.consent.standards; + +import android.util.Base64; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 30) +public class TcStringEncoderTest { + + @Test + public void encode_blanketConsent_returnsNonEmpty() { + String tc = TcStringEncoder.encode(42, 1, "EN", "DE", true); + assertNotNull(tc); + assertFalse(tc.isEmpty()); + } + + @Test + public void encode_blanketDeny_returnsNonEmpty() { + String tc = TcStringEncoder.encode(42, 1, "EN", "DE", false); + assertNotNull(tc); + assertFalse(tc.isEmpty()); + } + + @Test + public void encode_consentAndDeny_areDifferent() { + String consentTc = TcStringEncoder.encode(42, 1, "EN", "DE", true); + String denyTc = TcStringEncoder.encode(42, 1, "EN", "DE", false); + assertNotEquals("Consent and deny TC strings should differ", consentTc, denyTc); + } + + @Test + public void encode_isValidBase64Url() { + String tc = TcStringEncoder.encode(42, 1, "EN", "DE", true); + // Base64url should not contain +, /, or = + assertFalse("Should not contain +", tc.contains("+")); + assertFalse("Should not contain /", tc.contains("/")); + assertFalse("Should not contain =", tc.contains("=")); + } + + @Test + public void encode_decodesToValidBits() { + String tc = TcStringEncoder.encode(42, 1, "EN", "DE", true); + byte[] decoded = Base64.decode(tc, Base64.URL_SAFE); + assertNotNull(decoded); + assertTrue("Decoded bytes should be non-empty", decoded.length > 0); + } + + @Test + public void encode_versionIs2() { + String tc = TcStringEncoder.encode(42, 1, "EN", "DE", true); + byte[] decoded = Base64.decode(tc, Base64.URL_SAFE); + // Version is the first 6 bits + int version = (decoded[0] >> 2) & 0x3F; + assertEquals("Version should be 2", 2, version); + } + + @Test + public void encode_perPurpose_returnsNonEmpty() { + boolean[] purposes = new boolean[24]; + purposes[0] = true; // Purpose 1 + purposes[6] = true; // Purpose 7 + boolean[] purposesLI = new boolean[24]; + boolean[] specialFeatures = new boolean[12]; + + String tc = TcStringEncoder.encode(42, 1, "EN", "DE", + purposes, purposesLI, specialFeatures, 0); + assertNotNull(tc); + assertFalse(tc.isEmpty()); + } + + @Test + public void encode_cmpIdIsEncoded() { + // Encode with CMP ID 42 and 100 — they should produce different strings + String tc42 = TcStringEncoder.encode(42, 1, "EN", "DE", true); + String tc100 = TcStringEncoder.encode(100, 1, "EN", "DE", true); + assertNotEquals("Different CMP IDs should produce different strings", tc42, tc100); + } + + @Test + public void encode_differentLanguages_differ() { + String enTc = TcStringEncoder.encode(42, 1, "EN", "DE", true); + String frTc = TcStringEncoder.encode(42, 1, "FR", "DE", true); + assertNotEquals("Different languages should produce different strings", enTc, frTc); + } + + @Test + public void bitWriter_writeInt_correctBits() { + TcStringEncoder.BitWriter bw = new TcStringEncoder.BitWriter(); + bw.writeInt(2, 6); // Binary: 000010 + String base64 = bw.toBase64Url(); + byte[] decoded = Base64.decode(base64, Base64.URL_SAFE); + // 000010 + 00 padding = 00001000 = 8 + assertEquals(8, decoded[0] & 0xFF); + } + + @Test + public void bitWriter_writeBit_correctBits() { + TcStringEncoder.BitWriter bw = new TcStringEncoder.BitWriter(); + bw.writeBit(true); + bw.writeBit(false); + bw.writeBit(true); + bw.writeBit(false); + bw.writeBit(true); + bw.writeBit(false); + bw.writeBit(true); + bw.writeBit(false); + String base64 = bw.toBase64Url(); + byte[] decoded = Base64.decode(base64, Base64.URL_SAFE); + // 10101010 = 0xAA = 170 + assertEquals(0xAA, decoded[0] & 0xFF); + } + + @Test + public void bitWriter_writeLanguage_EN() { + TcStringEncoder.BitWriter bw = new TcStringEncoder.BitWriter(); + bw.writeLanguage("EN"); + // E = 4 (000100), N = 13 (001101) + // 000100 001101 = 12 bits + String base64 = bw.toBase64Url(); + byte[] decoded = Base64.decode(base64, Base64.URL_SAFE); + // 00010000 1101xxxx (padded to 16 bits) + assertEquals(0x10, decoded[0] & 0xFF); // 000100 00 + assertEquals(0xD0, decoded[1] & 0xF0); // 1101 0000 + } +} diff --git a/library/src/test/java/net/kollnig/consent/standards/TcfConsentManagerTest.java b/library/src/test/java/net/kollnig/consent/standards/TcfConsentManagerTest.java new file mode 100644 index 0000000..e2022e3 --- /dev/null +++ b/library/src/test/java/net/kollnig/consent/standards/TcfConsentManagerTest.java @@ -0,0 +1,193 @@ +package net.kollnig.consent.standards; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 30) +public class TcfConsentManagerTest { + + private Context context; + private TcfConsentManager tcf; + private SharedPreferences prefs; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + tcf = new TcfConsentManager(context, 42, 1, "DE"); + prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().clear().apply(); + } + + @Test + public void writeConsentSignals_gdprApplies_consentGiven() { + tcf.writeConsentSignals(true, true); + + assertEquals(1, prefs.getInt("IABTCF_gdprApplies", -1)); + assertEquals(42, prefs.getInt("IABTCF_CmpSdkID", -1)); + assertEquals(1, prefs.getInt("IABTCF_CmpSdkVersion", -1)); + assertEquals(4, prefs.getInt("IABTCF_PolicyVersion", -1)); + assertEquals("DE", prefs.getString("IABTCF_PublisherCC", null)); + + // All purposes consented + String purposeConsents = prefs.getString("IABTCF_PurposeConsents", ""); + assertEquals(TcfConsentManager.PURPOSE_COUNT, purposeConsents.length()); + assertEquals("11111111111", purposeConsents); + } + + @Test + public void writeConsentSignals_gdprApplies_consentDenied() { + tcf.writeConsentSignals(true, false); + + assertEquals(1, prefs.getInt("IABTCF_gdprApplies", -1)); + + String purposeConsents = prefs.getString("IABTCF_PurposeConsents", ""); + assertEquals("00000000000", purposeConsents); + + String specialFeatures = prefs.getString("IABTCF_SpecialFeaturesOptIns", ""); + assertEquals("00", specialFeatures); + } + + @Test + public void writeConsentSignals_gdprDoesNotApply() { + tcf.writeConsentSignals(false, false); + + assertEquals(0, prefs.getInt("IABTCF_gdprApplies", -1)); + } + + @Test + public void writeConsentSignals_perPurpose() { + boolean[] purposes = new boolean[TcfConsentManager.PURPOSE_COUNT]; + purposes[0] = true; // Purpose 1: Store/access info + purposes[1] = false; // Purpose 2: Basic ads + purposes[6] = true; // Purpose 7: Measure ad performance + + boolean[] specialFeatures = {false, true}; // Scan device characteristics + + tcf.writeConsentSignals(true, purposes, specialFeatures); + + String purposeConsents = prefs.getString("IABTCF_PurposeConsents", ""); + assertEquals('1', purposeConsents.charAt(0)); + assertEquals('0', purposeConsents.charAt(1)); + assertEquals('1', purposeConsents.charAt(6)); + + String specialFeaturesStr = prefs.getString("IABTCF_SpecialFeaturesOptIns", ""); + assertEquals("01", specialFeaturesStr); + } + + @Test(expected = IllegalArgumentException.class) + public void writeConsentSignals_perPurpose_wrongPurposeCount() { + boolean[] purposes = new boolean[3]; // Wrong count + boolean[] specialFeatures = new boolean[TcfConsentManager.SPECIAL_FEATURE_COUNT]; + tcf.writeConsentSignals(true, purposes, specialFeatures); + } + + @Test(expected = IllegalArgumentException.class) + public void writeConsentSignals_perPurpose_wrongSpecialFeatureCount() { + boolean[] purposes = new boolean[TcfConsentManager.PURPOSE_COUNT]; + boolean[] specialFeatures = new boolean[5]; // Wrong count + tcf.writeConsentSignals(true, purposes, specialFeatures); + } + + @Test + public void clearConsentSignals_removesAllKeys() { + tcf.writeConsentSignals(true, true); + assertTrue(prefs.contains("IABTCF_PurposeConsents")); + + tcf.clearConsentSignals(); + assertFalse(prefs.contains("IABTCF_PurposeConsents")); + assertFalse(prefs.contains("IABTCF_gdprApplies")); + assertFalse(prefs.contains("IABTCF_CmpSdkID")); + } + + @Test + public void hasConsentSignals_falseInitially() { + assertFalse(tcf.hasConsentSignals()); + } + + @Test + public void hasConsentSignals_trueAfterWrite() { + tcf.writeConsentSignals(true, true); + assertTrue(tcf.hasConsentSignals()); + } + + @Test + public void getPurposeConsents_returnsCorrectValues() { + tcf.writeConsentSignals(true, true); + boolean[] consents = tcf.getPurposeConsents(); + assertEquals(TcfConsentManager.PURPOSE_COUNT, consents.length); + for (boolean c : consents) { + assertTrue(c); + } + } + + @Test + public void getPurposeConsents_allFalseWhenDenied() { + tcf.writeConsentSignals(true, false); + boolean[] consents = tcf.getPurposeConsents(); + for (boolean c : consents) { + assertFalse(c); + } + } + + @Test + public void storedInDefaultSharedPreferences() { + // TCF spec requires keys in default SharedPreferences + tcf.writeConsentSignals(true, true); + + SharedPreferences defaultPrefs = PreferenceManager.getDefaultSharedPreferences(context); + assertTrue(defaultPrefs.contains("IABTCF_PurposeConsents")); + } + + @Test + public void writeConsentSignals_writesTcString() { + tcf.writeConsentSignals(true, true); + + String tcString = prefs.getString("IABTCF_TCString", null); + assertNotNull("TC String should be written", tcString); + assertFalse("TC String should not be empty", tcString.isEmpty()); + } + + @Test + public void writeConsentSignals_tcStringDiffersForConsentAndDeny() { + tcf.writeConsentSignals(true, true); + String consentTc = prefs.getString("IABTCF_TCString", null); + + tcf.writeConsentSignals(true, false); + String denyTc = prefs.getString("IABTCF_TCString", null); + + assertNotEquals("TC String should differ for consent vs deny", consentTc, denyTc); + } + + @Test + public void writeConsentSignals_perPurpose_writesTcString() { + boolean[] purposes = new boolean[TcfConsentManager.PURPOSE_COUNT]; + purposes[0] = true; + boolean[] specialFeatures = {false, false}; + + tcf.writeConsentSignals(true, purposes, specialFeatures); + + String tcString = prefs.getString("IABTCF_TCString", null); + assertNotNull("TC String should be written for per-purpose consent", tcString); + } + + @Test + public void clearConsentSignals_removesTcString() { + tcf.writeConsentSignals(true, true); + assertNotNull(prefs.getString("IABTCF_TCString", null)); + + tcf.clearConsentSignals(); + assertNull(prefs.getString("IABTCF_TCString", null)); + } +} diff --git a/library/src/test/java/net/kollnig/consent/standards/UsPrivacyManagerTest.java b/library/src/test/java/net/kollnig/consent/standards/UsPrivacyManagerTest.java new file mode 100644 index 0000000..31a8576 --- /dev/null +++ b/library/src/test/java/net/kollnig/consent/standards/UsPrivacyManagerTest.java @@ -0,0 +1,116 @@ +package net.kollnig.consent.standards; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 30) +public class UsPrivacyManagerTest { + + private Context context; + private UsPrivacyManager usPrivacy; + private SharedPreferences prefs; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + usPrivacy = new UsPrivacyManager(context); + prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().clear().apply(); + } + + @Test + public void writeConsentSignal_ccpaApplies_consentGiven() { + usPrivacy.writeConsentSignal(true, true); + + String privacy = prefs.getString("IABUSPrivacy_String", null); + assertNotNull(privacy); + assertEquals("1YNN", privacy); + } + + @Test + public void writeConsentSignal_ccpaApplies_consentDenied() { + usPrivacy.writeConsentSignal(true, false); + + String privacy = prefs.getString("IABUSPrivacy_String", null); + assertEquals("1YYN", privacy); + } + + @Test + public void writeConsentSignal_ccpaDoesNotApply() { + usPrivacy.writeConsentSignal(false, false); + + String privacy = prefs.getString("IABUSPrivacy_String", null); + assertEquals("1---", privacy); + } + + @Test + public void clearConsentSignal_removesKey() { + usPrivacy.writeConsentSignal(true, true); + assertNotNull(prefs.getString("IABUSPrivacy_String", null)); + + usPrivacy.clearConsentSignal(); + assertNull(prefs.getString("IABUSPrivacy_String", null)); + } + + @Test + public void getPrivacyString_nullInitially() { + assertNull(usPrivacy.getPrivacyString()); + } + + @Test + public void getPrivacyString_returnsWrittenValue() { + usPrivacy.writeConsentSignal(true, false); + assertEquals("1YYN", usPrivacy.getPrivacyString()); + } + + @Test + public void hasOptedOutOfSale_trueWhenDenied() { + usPrivacy.writeConsentSignal(true, false); + assertTrue(usPrivacy.hasOptedOutOfSale()); + } + + @Test + public void hasOptedOutOfSale_falseWhenConsented() { + usPrivacy.writeConsentSignal(true, true); + assertFalse(usPrivacy.hasOptedOutOfSale()); + } + + @Test + public void hasOptedOutOfSale_falseWhenNotSet() { + assertFalse(usPrivacy.hasOptedOutOfSale()); + } + + @Test + public void hasOptedOutOfSale_falseWhenNotApplicable() { + usPrivacy.writeConsentSignal(false, false); + assertFalse(usPrivacy.hasOptedOutOfSale()); + } + + @Test + public void privacyStringFormat_startsWithVersion1() { + usPrivacy.writeConsentSignal(true, true); + String privacy = usPrivacy.getPrivacyString(); + assertTrue(privacy.startsWith("1")); + } + + @Test + public void privacyStringFormat_exactlyFourChars() { + usPrivacy.writeConsentSignal(true, true); + assertEquals(4, usPrivacy.getPrivacyString().length()); + + usPrivacy.writeConsentSignal(false, false); + assertEquals(4, usPrivacy.getPrivacyString().length()); + } +} diff --git a/plugin/build.gradle b/plugin/build.gradle new file mode 100644 index 0000000..9e54b2b --- /dev/null +++ b/plugin/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'java-gradle-plugin' +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + // Android Gradle Plugin API — needed for ConsentPlugin and ConsentClassVisitorFactory + implementation 'com.android.tools.build:gradle-api:7.2.1' + + // Kotlin stdlib — needed because AGP's API uses Kotlin function types + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.7.10' + + // ASM bytecode manipulation + implementation 'org.ow2.asm:asm:9.7' + implementation 'org.ow2.asm:asm-commons:9.7' + + // Testing + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.ow2.asm:asm-util:9.7' +} + +gradlePlugin { + plugins { + consentPlugin { + id = 'net.kollnig.consent.plugin' + implementationClass = 'net.kollnig.consent.plugin.ConsentPlugin' + } + } +} diff --git a/plugin/gradle/wrapper/gradle-wrapper.jar b/plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/plugin/gradle/wrapper/gradle-wrapper.properties b/plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0d23ac0 --- /dev/null +++ b/plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/plugin/gradlew b/plugin/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/plugin/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/plugin/gradlew.bat b/plugin/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/plugin/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/plugin/settings.gradle b/plugin/settings.gradle new file mode 100644 index 0000000..cf8abcf --- /dev/null +++ b/plugin/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = 'consent-plugin' diff --git a/plugin/src/main/java/net/kollnig/consent/plugin/ConsentClassVisitor.java b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentClassVisitor.java new file mode 100644 index 0000000..3676a0e --- /dev/null +++ b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentClassVisitor.java @@ -0,0 +1,40 @@ +package net.kollnig.consent.plugin; + +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * ASM ClassVisitor that transforms SDK classes to inject consent checks. + * + * For each method that matches a rule in ConsentTransformRules, it wraps + * the method visitor with ConsentMethodVisitor which injects a consent + * check at the very beginning of the method. + */ +public class ConsentClassVisitor extends ClassVisitor { + + private final String className; // dot-separated + private final String internalName; // slash-separated + + public ConsentClassVisitor(ClassVisitor classVisitor, String className) { + super(Opcodes.ASM9, classVisitor); + this.className = className; + this.internalName = className.replace('.', '/'); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); + + // Check if this method matches any of our consent rules + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + internalName, name, descriptor); + + if (rule != null) { + return new ConsentMethodVisitor(mv, access, name, descriptor, rule); + } + + return mv; + } +} diff --git a/plugin/src/main/java/net/kollnig/consent/plugin/ConsentClassVisitorFactory.java b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentClassVisitorFactory.java new file mode 100644 index 0000000..c67144b --- /dev/null +++ b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentClassVisitorFactory.java @@ -0,0 +1,31 @@ +package net.kollnig.consent.plugin; + +import com.android.build.api.instrumentation.AsmClassVisitorFactory; +import com.android.build.api.instrumentation.ClassContext; +import com.android.build.api.instrumentation.ClassData; +import com.android.build.api.instrumentation.InstrumentationParameters; + +import org.objectweb.asm.ClassVisitor; + +/** + * Factory that creates ASM ClassVisitors to transform SDK classes at build time. + * + * The Android Gradle Plugin calls isInstrumentable() for every class in the app + * and its dependencies. For classes we want to modify, it calls + * createClassVisitor() to get a visitor that rewrites the bytecode. + */ +public abstract class ConsentClassVisitorFactory + implements AsmClassVisitorFactory { + + @Override + public ClassVisitor createClassVisitor(ClassContext classContext, ClassVisitor nextClassVisitor) { + return new ConsentClassVisitor(nextClassVisitor, classContext.getCurrentClassData().getClassName()); + } + + @Override + public boolean isInstrumentable(ClassData classData) { + // Only transform classes that we have consent rules for + return ConsentTransformRules.hasRulesForClass( + classData.getClassName().replace('.', '/')); + } +} diff --git a/plugin/src/main/java/net/kollnig/consent/plugin/ConsentMethodVisitor.java b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentMethodVisitor.java new file mode 100644 index 0000000..0fadfc8 --- /dev/null +++ b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentMethodVisitor.java @@ -0,0 +1,205 @@ +package net.kollnig.consent.plugin; + +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +/** + * ASM MethodVisitor that injects a consent check at the start of a method. + * + * The injected bytecode is equivalent to: + * + * // For BLOCK action (void methods): + * if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent("library_id"))) + * return; + * + * // For BLOCK action (methods returning a value): + * if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent("library_id"))) + * return defaultValue; // null, false, 0, etc. + * + * // For THROW_IO_EXCEPTION action: + * if (!Boolean.TRUE.equals(ConsentManager.getInstance().hasConsent("library_id"))) + * throw new IOException("Blocked without consent"); + * + * This runs before any of the original method body executes. + */ +public class ConsentMethodVisitor extends MethodVisitor { + + private static final String CONSENT_MANAGER = "net/kollnig/consent/ConsentManager"; + private static final String BOOLEAN_CLASS = "java/lang/Boolean"; + private static final String GPC_INTERCEPTOR = "net/kollnig/consent/standards/GpcInterceptor"; + + private final int access; + private final String methodName; + private final String descriptor; + private final ConsentTransformRules.Rule rule; + + public ConsentMethodVisitor(MethodVisitor methodVisitor, int access, + String methodName, String descriptor, + ConsentTransformRules.Rule rule) { + super(Opcodes.ASM9, methodVisitor); + this.access = access; + this.methodName = methodName; + this.descriptor = descriptor; + this.rule = rule; + } + + @Override + public void visitCode() { + super.visitCode(); + injectConsentCheck(); + } + + private void injectConsentCheck() { + if (rule.action == ConsentTransformRules.Action.INJECT_GPC_HEADER) { + injectGpcHeader(); + return; + } + + Label proceedLabel = new Label(); + + // --- Generate: ConsentManager.getInstance() --- + mv.visitMethodInsn( + Opcodes.INVOKESTATIC, + CONSENT_MANAGER, + "getInstance", + "()L" + CONSENT_MANAGER + ";", + false); + + // --- Generate: .hasConsent("library_id") --- + mv.visitLdcInsn(rule.libraryId); + mv.visitMethodInsn( + Opcodes.INVOKEVIRTUAL, + CONSENT_MANAGER, + "hasConsent", + "(Ljava/lang/String;)Ljava/lang/Boolean;", + false); + + // --- Generate: Boolean.TRUE.equals(result) --- + mv.visitFieldInsn( + Opcodes.GETSTATIC, + BOOLEAN_CLASS, + "TRUE", + "L" + BOOLEAN_CLASS + ";"); + + // Swap so it's Boolean.TRUE.equals(hasConsentResult) + mv.visitInsn(Opcodes.SWAP); + + mv.visitMethodInsn( + Opcodes.INVOKEVIRTUAL, + BOOLEAN_CLASS, + "equals", + "(Ljava/lang/Object;)Z", + false); + + // --- If consent granted (true), jump to proceed --- + mv.visitJumpInsn(Opcodes.IFNE, proceedLabel); + + // --- No consent: take action --- + switch (rule.action) { + case THROW_IO_EXCEPTION: + mv.visitTypeInsn(Opcodes.NEW, "java/io/IOException"); + mv.visitInsn(Opcodes.DUP); + mv.visitLdcInsn("Blocked access to " + rule.className.replace('/', '.') + + "." + rule.methodName + " without consent"); + mv.visitMethodInsn( + Opcodes.INVOKESPECIAL, + "java/io/IOException", + "", + "(Ljava/lang/String;)V", + false); + mv.visitInsn(Opcodes.ATHROW); + break; + + case BLOCK: + default: + injectDefaultReturn(); + break; + } + + // --- proceedLabel: original method body follows --- + mv.visitLabel(proceedLabel); + } + + /** + * Inject GPC header into an HTTP request builder's build() method. + * + * Works for any builder that has a header(String, String) method: + * OkHttp3, OkHttp2, Cronet, etc. + * + * Generated bytecode equivalent: + * if (GpcInterceptor.isEnabled()) { + * this.header("Sec-GPC", "1"); // or addHeader(), etc. + * } + * // ... original build() code follows + */ + private void injectGpcHeader() { + Label skipLabel = new Label(); + + // --- Generate: GpcInterceptor.isEnabled() --- + mv.visitMethodInsn( + Opcodes.INVOKESTATIC, + GPC_INTERCEPTOR, + "isEnabled", + "()Z", + false); + + // --- If GPC not enabled, skip header injection --- + mv.visitJumpInsn(Opcodes.IFEQ, skipLabel); + + // --- Generate: this.header("Sec-GPC", "1") --- + // Uses the rule's headerMethodName and headerMethodDesc so this works + // for any HTTP client (OkHttp3, OkHttp2, Cronet, etc.) + mv.visitVarInsn(Opcodes.ALOAD, 0); // this (the builder instance) + mv.visitLdcInsn("Sec-GPC"); + mv.visitLdcInsn("1"); + mv.visitMethodInsn( + Opcodes.INVOKEVIRTUAL, + rule.className, + rule.headerMethodName, + rule.headerMethodDesc, + false); + mv.visitInsn(Opcodes.POP); // discard returned builder (it's `this`) + + // --- skipLabel: original build() code follows --- + mv.visitLabel(skipLabel); + } + + /** + * Inject the appropriate return instruction based on the method's return type. + */ + private void injectDefaultReturn() { + Type returnType = Type.getReturnType(descriptor); + switch (returnType.getSort()) { + case Type.VOID: + mv.visitInsn(Opcodes.RETURN); + break; + case Type.BOOLEAN: + case Type.BYTE: + case Type.CHAR: + case Type.SHORT: + case Type.INT: + mv.visitInsn(Opcodes.ICONST_0); + mv.visitInsn(Opcodes.IRETURN); + break; + case Type.LONG: + mv.visitInsn(Opcodes.LCONST_0); + mv.visitInsn(Opcodes.LRETURN); + break; + case Type.FLOAT: + mv.visitInsn(Opcodes.FCONST_0); + mv.visitInsn(Opcodes.FRETURN); + break; + case Type.DOUBLE: + mv.visitInsn(Opcodes.DCONST_0); + mv.visitInsn(Opcodes.DRETURN); + break; + default: + // Object types: return null + mv.visitInsn(Opcodes.ACONST_NULL); + mv.visitInsn(Opcodes.ARETURN); + break; + } + } +} diff --git a/plugin/src/main/java/net/kollnig/consent/plugin/ConsentPlugin.java b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentPlugin.java new file mode 100644 index 0000000..2179e6c --- /dev/null +++ b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentPlugin.java @@ -0,0 +1,48 @@ +package net.kollnig.consent.plugin; + +import com.android.build.api.instrumentation.FramesComputationMode; +import com.android.build.api.instrumentation.InstrumentationScope; +import com.android.build.api.variant.AndroidComponentsExtension; +import com.android.build.api.variant.Variant; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +import kotlin.Unit; + +/** + * Gradle plugin that transforms third-party SDK bytecode at build time + * to inject consent checks. + * + * This eliminates the need for runtime method hooking (YAHFA/LSPlant). + * The consent checks are baked into the APK during compilation, which means: + * - Works on ALL Android versions (no ART dependency) + * - No Play Protect flags + * - No OEM compatibility issues + * - No JIT/inlining breakage + * + * Usage in app/build.gradle: + * plugins { + * id 'net.kollnig.consent.plugin' + * } + */ +public class ConsentPlugin implements Plugin { + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public void apply(Project project) { + AndroidComponentsExtension androidComponents = project.getExtensions() + .getByType(AndroidComponentsExtension.class); + + androidComponents.onVariants(androidComponents.selector().all(), variant -> { + ((Variant) variant).getInstrumentation().transformClassesWith( + ConsentClassVisitorFactory.class, + InstrumentationScope.ALL, + params -> Unit.INSTANCE + ); + ((Variant) variant).getInstrumentation().setAsmFramesComputationMode( + FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS + ); + }); + } +} diff --git a/plugin/src/main/java/net/kollnig/consent/plugin/ConsentTransformRules.java b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentTransformRules.java new file mode 100644 index 0000000..7a2cbe2 --- /dev/null +++ b/plugin/src/main/java/net/kollnig/consent/plugin/ConsentTransformRules.java @@ -0,0 +1,187 @@ +package net.kollnig.consent.plugin; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Defines which SDK methods should have consent checks injected. + * + * Each rule specifies: + * - The class and method to transform + * - The consent library ID to check + * - The action to take when consent is denied (BLOCK or MODIFY_ARGS) + * + * These rules replace the runtime hooks that were previously in: + * - GoogleAdsLibrary (YAHFA hooks on initialize, loadAd) + * - AdvertisingIdLibrary (YAHFA hook on getAdvertisingIdInfo) + * - AppsFlyerLibrary (YAHFA hook on start) + * - FlurryLibrary (YAHFA hook on build) + * - InMobiLibrary (YAHFA hook on init) + * - AdColonyLibrary (YAHFA hook on configure) + * - VungleLibrary (YAHFA hook on configure) + */ +public class ConsentTransformRules { + + public enum Action { + /** Return early (void) or return default value if no consent */ + BLOCK, + /** Throw IOException if no consent (for AdvertisingIdClient) */ + THROW_IO_EXCEPTION, + /** Inject Sec-GPC: 1 header into HTTP request builder (for GPC) */ + INJECT_GPC_HEADER + } + + public static class Rule { + public final String className; // internal name: com/example/Foo + public final String methodName; + public final String methodDesc; // JNI descriptor + public final String libraryId; // consent library ID + public final Action action; + + // For INJECT_GPC_HEADER: how to add the header on this builder type + public final String headerMethodName; // e.g. "header", "addHeader" + public final String headerMethodDesc; // e.g. "(Ljava/lang/String;Ljava/lang/String;)L...Builder;" + + public Rule(String className, String methodName, String methodDesc, + String libraryId, Action action) { + this(className, methodName, methodDesc, libraryId, action, null, null); + } + + public Rule(String className, String methodName, String methodDesc, + String libraryId, Action action, + String headerMethodName, String headerMethodDesc) { + this.className = className; + this.methodName = methodName; + this.methodDesc = methodDesc; + this.libraryId = libraryId; + this.action = action; + this.headerMethodName = headerMethodName; + this.headerMethodDesc = headerMethodDesc; + } + } + + // Map from internal class name -> list of rules for that class + private static final Map> RULES = new HashMap<>(); + + static { + // Google Ads — block initialize() and loadAd() without consent + addRule("com/google/android/gms/ads/MobileAds", "initialize", + "(Landroid/content/Context;)V", + "google_ads", Action.BLOCK); + addRule("com/google/android/gms/ads/MobileAds", "initialize", + "(Landroid/content/Context;Lcom/google/android/gms/ads/initialization/OnInitializationCompleteListener;)V", + "google_ads", Action.BLOCK); + addRule("com/google/android/gms/ads/BaseAdView", "loadAd", + "(Lcom/google/android/gms/ads/AdRequest;)V", + "google_ads", Action.BLOCK); + + // Advertising ID — throw IOException without consent + addRule("com/google/android/gms/ads/identifier/AdvertisingIdClient", + "getAdvertisingIdInfo", + "(Landroid/content/Context;)Lcom/google/android/gms/ads/identifier/AdvertisingIdClient$Info;", + "google_ads_identifier", Action.THROW_IO_EXCEPTION); + + // AppsFlyer — block start() without consent + addRule("com/appsflyer/AppsFlyerLib", "start", + "(Landroid/content/Context;Ljava/lang/String;Lcom/appsflyer/attribution/AppsFlyerRequestListener;)V", + "appsflyer", Action.BLOCK); + + // Flurry — block build() without consent + addRule("com/flurry/android/FlurryAgent$Builder", "build", + "(Landroid/content/Context;Ljava/lang/String;)V", + "flurry", Action.BLOCK); + + // InMobi — block init() without consent + addRule("com/inmobi/sdk/InMobiSdk", "init", + "(Landroid/content/Context;Ljava/lang/String;Lorg/json/JSONObject;Lcom/inmobi/sdk/SdkInitializationListener;)V", + "inmobi", Action.BLOCK); + + // AdColony — block configure() without consent + // Note: AdColony obfuscates method names, so we match by signature + // The actual method name may vary; the visitor checks signature too + addRule("com/adcolony/sdk/AdColony", "configure", + "(Landroid/content/Context;Lcom/adcolony/sdk/AdColonyAppOptions;Ljava/lang/String;)Z", + "adcolony", Action.BLOCK); + + // Vungle — block init() without consent + addRule("com/vungle/warren/Vungle", "init", + "(Ljava/lang/String;Landroid/content/Context;Lcom/vungle/warren/InitCallback;)V", + "vungle", Action.BLOCK); + + // ---- GPC header injection ---- + // Inject Sec-GPC: 1 into HTTP request builders at build time. + // The header is added in the builder before TLS, so cert pinning + // is irrelevant — we're modifying the code that constructs requests. + + // OkHttp3 — used by most modern ad SDKs (bundled internally) + addGpcRule("okhttp3/Request$Builder", "build", + "()Lokhttp3/Request;", + "header", + "(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;"); + + // OkHttp2 — used by older SDKs that haven't upgraded + addGpcRule("com/squareup/okhttp/Request$Builder", "build", + "()Lcom/squareup/okhttp/Request;", + "header", + "(Ljava/lang/String;Ljava/lang/String;)Lcom/squareup/okhttp/Request$Builder;"); + + // Cronet — used by Google SDKs (Ads, Play Services) + addGpcRule("org/chromium/net/UrlRequest$Builder", "build", + "()Lorg/chromium/net/UrlRequest;", + "addHeader", + "(Ljava/lang/String;Ljava/lang/String;)Lorg/chromium/net/UrlRequest$Builder;"); + + // Volley — used by some apps and older SDKs + // Volley doesn't have a builder pattern — headers are added via + // getHeaders() override. Instead we transform the base Request + // constructor to set the header map. This is handled differently: + // we hook HurlStack.createConnection() which uses HttpURLConnection. + // For now, Volley is covered by the app-level GpcInterceptor.applyTo(). + } + + private static void addRule(String className, String methodName, String methodDesc, + String libraryId, Action action) { + RULES.computeIfAbsent(className, k -> new ArrayList<>()) + .add(new Rule(className, methodName, methodDesc, libraryId, action)); + } + + private static void addGpcRule(String className, String buildMethodName, + String buildMethodDesc, + String headerMethodName, String headerMethodDesc) { + RULES.computeIfAbsent(className, k -> new ArrayList<>()) + .add(new Rule(className, buildMethodName, buildMethodDesc, + "_gpc", Action.INJECT_GPC_HEADER, + headerMethodName, headerMethodDesc)); + } + + /** + * Check if we have any transform rules for this class. + */ + public static boolean hasRulesForClass(String internalClassName) { + return RULES.containsKey(internalClassName); + } + + /** + * Get all rules for a class. + */ + public static List getRulesForClass(String internalClassName) { + return RULES.getOrDefault(internalClassName, new ArrayList<>()); + } + + /** + * Find a matching rule for a specific method. + */ + public static Rule findRule(String internalClassName, String methodName, String methodDesc) { + List rules = RULES.get(internalClassName); + if (rules == null) return null; + + for (Rule rule : rules) { + if (rule.methodName.equals(methodName) && rule.methodDesc.equals(methodDesc)) { + return rule; + } + } + return null; + } +} diff --git a/plugin/src/test/java/net/kollnig/consent/plugin/ConsentClassVisitorTest.java b/plugin/src/test/java/net/kollnig/consent/plugin/ConsentClassVisitorTest.java new file mode 100644 index 0000000..74bfd5b --- /dev/null +++ b/plugin/src/test/java/net/kollnig/consent/plugin/ConsentClassVisitorTest.java @@ -0,0 +1,134 @@ +package net.kollnig.consent.plugin; + +import org.junit.Test; +import org.objectweb.asm.*; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Tests that ConsentClassVisitor correctly routes method transformation — + * only methods matching a rule get transformed, others pass through unchanged. + */ +public class ConsentClassVisitorTest { + + /** + * Build a class with multiple methods, transform it, and return + * which methods had consent checks injected. + */ + private List findTransformedMethods(String className, String[][] methods) { + // Build a synthetic class with the given methods + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, className, null, + "java/lang/Object", null); + + // Constructor + MethodVisitor init = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(Opcodes.ALOAD, 0); + init.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + init.visitInsn(Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + + for (String[] method : methods) { + String name = method[0]; + String desc = method[1]; + MethodVisitor mv = cw.visitMethod( + Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, name, desc, null, null); + mv.visitCode(); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + + cw.visitEnd(); + byte[] original = cw.toByteArray(); + + // Transform + ClassReader cr = new ClassReader(original); + ClassWriter tcw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + ConsentClassVisitor visitor = new ConsentClassVisitor(tcw, className.replace('/', '.')); + cr.accept(visitor, ClassReader.EXPAND_FRAMES); + byte[] transformed = tcw.toByteArray(); + + // Find which methods now reference ConsentManager + List transformedMethods = new ArrayList<>(); + ClassReader tr = new ClassReader(transformed); + tr.accept(new ClassVisitor(Opcodes.ASM9) { + String currentMethod; + + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exceptions) { + currentMethod = name; + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String mName, String mDesc, boolean itf) { + if (owner.equals("net/kollnig/consent/ConsentManager") + && !transformedMethods.contains(currentMethod)) { + transformedMethods.add(currentMethod); + } + } + }; + } + }, 0); + + return transformedMethods; + } + + @Test + public void transformsMatchingMethod_leavesOthersAlone() { + List transformed = findTransformedMethods( + "com/google/android/gms/ads/MobileAds", + new String[][]{ + {"initialize", "(Landroid/content/Context;)V"}, + {"getVersion", "()Ljava/lang/String;"}, + {"disableMediationAdapterInitialization", "(Landroid/content/Context;)V"} + }); + + assertTrue("initialize should be transformed", transformed.contains("initialize")); + assertFalse("getVersion should NOT be transformed", transformed.contains("getVersion")); + assertFalse("disableMediationAdapterInitialization should NOT be transformed", + transformed.contains("disableMediationAdapterInitialization")); + } + + @Test + public void doesNotTransformUnknownClass() { + List transformed = findTransformedMethods( + "com/example/MyOwnClass", + new String[][]{ + {"initialize", "(Landroid/content/Context;)V"}, + {"doStuff", "()V"} + }); + + assertTrue("No methods should be transformed for unknown class", transformed.isEmpty()); + } + + @Test + public void transformsMultipleMethodsInSameClass() { + // MobileAds has two initialize overloads + List transformed = findTransformedMethods( + "com/google/android/gms/ads/MobileAds", + new String[][]{ + {"initialize", "(Landroid/content/Context;)V"}, + {"initialize", "(Landroid/content/Context;Lcom/google/android/gms/ads/initialization/OnInitializationCompleteListener;)V"}, + {"getVersion", "()Ljava/lang/String;"} + }); + + assertTrue("initialize should be transformed", transformed.contains("initialize")); + assertFalse("getVersion should NOT be transformed", transformed.contains("getVersion")); + } + + @Test + public void constructorIsNeverTransformed() { + List transformed = findTransformedMethods( + "com/google/android/gms/ads/MobileAds", + new String[][]{ + {"initialize", "(Landroid/content/Context;)V"} + }); + + assertFalse(" should never be transformed", transformed.contains("")); + } +} diff --git a/plugin/src/test/java/net/kollnig/consent/plugin/ConsentMethodVisitorTest.java b/plugin/src/test/java/net/kollnig/consent/plugin/ConsentMethodVisitorTest.java new file mode 100644 index 0000000..906e4ea --- /dev/null +++ b/plugin/src/test/java/net/kollnig/consent/plugin/ConsentMethodVisitorTest.java @@ -0,0 +1,541 @@ +package net.kollnig.consent.plugin; + +import org.junit.Test; +import org.objectweb.asm.*; +import org.objectweb.asm.util.CheckClassAdapter; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.junit.Assert.*; + +/** + * Tests that ConsentMethodVisitor generates valid bytecode. + * + * These tests create synthetic classes with known methods, run them through + * the consent transform, and verify: + * 1. The output bytecode is structurally valid (passes ASM's CheckClassAdapter) + * 2. The consent check instructions are present + * 3. The correct return type is used for BLOCK actions + * 4. IOException is thrown for THROW_IO_EXCEPTION actions + */ +public class ConsentMethodVisitorTest { + + /** + * Create a minimal synthetic class with one method, transform it, + * and return the transformed bytecode. + */ + private byte[] transformMethod(String className, String methodName, String methodDesc, + ConsentTransformRules.Rule rule, boolean isStatic) { + // Build a synthetic class + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, className, null, + "java/lang/Object", null); + + // Add a constructor + MethodVisitor init = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(Opcodes.ALOAD, 0); + init.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + init.visitInsn(Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + + // Add the target method with a simple body + int access = Opcodes.ACC_PUBLIC | (isStatic ? Opcodes.ACC_STATIC : 0); + MethodVisitor mv = cw.visitMethod(access, methodName, methodDesc, null, + methodDesc.contains("IOException") ? new String[]{"java/io/IOException"} : null); + mv.visitCode(); + + // Simple method body based on return type + Type returnType = Type.getReturnType(methodDesc); + switch (returnType.getSort()) { + case Type.VOID: + mv.visitInsn(Opcodes.RETURN); + break; + case Type.BOOLEAN: + case Type.INT: + mv.visitInsn(Opcodes.ICONST_1); + mv.visitInsn(Opcodes.IRETURN); + break; + case Type.OBJECT: + mv.visitInsn(Opcodes.ACONST_NULL); + mv.visitInsn(Opcodes.ARETURN); + break; + default: + mv.visitInsn(Opcodes.RETURN); + } + + mv.visitMaxs(1, Type.getArgumentTypes(methodDesc).length + (isStatic ? 0 : 1)); + mv.visitEnd(); + cw.visitEnd(); + + byte[] original = cw.toByteArray(); + + // Now transform it using our visitors + ClassReader cr = new ClassReader(original); + ClassWriter transformedCw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + ConsentClassVisitor consentVisitor = new ConsentClassVisitor(transformedCw, className.replace('/', '.')); + + // We need to simulate the rule being found — override the class visitor's method matching + ClassVisitor ruleInjector = new ClassVisitor(Opcodes.ASM9, transformedCw) { + @Override + public MethodVisitor visitMethod(int acc, String name, String desc, String sig, String[] exceptions) { + MethodVisitor baseMv = super.visitMethod(acc, name, desc, sig, exceptions); + if (name.equals(methodName) && desc.equals(methodDesc)) { + return new ConsentMethodVisitor(baseMv, acc, name, desc, rule); + } + return baseMv; + } + }; + + cr.accept(ruleInjector, ClassReader.EXPAND_FRAMES); + return transformedCw.toByteArray(); + } + + /** + * Verify that bytecode is structurally valid using ASM's CheckClassAdapter. + */ + private void assertValidBytecode(byte[] bytecode) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + try { + CheckClassAdapter.verify(new ClassReader(bytecode), false, pw); + } catch (Exception e) { + fail("Invalid bytecode: " + e.getMessage() + "\n" + sw.toString()); + } + String errors = sw.toString(); + if (!errors.isEmpty()) { + fail("Bytecode verification errors:\n" + errors); + } + } + + /** + * Check that the transformed bytecode references ConsentManager. + */ + private boolean referencesConsentManager(byte[] bytecode) { + ClassReader cr = new ClassReader(bytecode); + boolean[] found = {false}; + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String mName, String mDesc, boolean itf) { + if (owner.equals("net/kollnig/consent/ConsentManager")) { + found[0] = true; + } + } + }; + } + }, 0); + return found[0]; + } + + /** + * Check that the transformed bytecode contains a specific string constant (LDC). + */ + private boolean containsStringConstant(byte[] bytecode, String value) { + ClassReader cr = new ClassReader(bytecode); + boolean[] found = {false}; + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitLdcInsn(Object cst) { + if (value.equals(cst)) { + found[0] = true; + } + } + }; + } + }, 0); + return found[0]; + } + + /** + * Check that the transformed bytecode contains an ATHROW instruction. + */ + private boolean containsAthrow(byte[] bytecode) { + ClassReader cr = new ClassReader(bytecode); + boolean[] found = {false}; + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitInsn(int opcode) { + if (opcode == Opcodes.ATHROW) { + found[0] = true; + } + } + }; + } + }, 0); + return found[0]; + } + + // ---- BLOCK action tests ---- + + @Test + public void blockAction_voidMethod_producesValidBytecode() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "init", "()V", "test_sdk", + ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "init", "()V", rule, true); + assertValidBytecode(result); + } + + @Test + public void blockAction_voidMethod_referencesConsentManager() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "init", "()V", "test_sdk", + ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "init", "()V", rule, true); + assertTrue("Should reference ConsentManager", referencesConsentManager(result)); + } + + @Test + public void blockAction_voidMethod_containsLibraryId() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "init", "()V", "my_library_id", + ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "init", "()V", rule, true); + assertTrue("Should contain library ID", containsStringConstant(result, "my_library_id")); + } + + @Test + public void blockAction_booleanMethod_producesValidBytecode() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "configure", "()Z", "test_sdk", + ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "configure", "()Z", rule, true); + assertValidBytecode(result); + } + + @Test + public void blockAction_objectMethod_producesValidBytecode() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "getData", "()Ljava/lang/Object;", "test_sdk", + ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "getData", + "()Ljava/lang/Object;", rule, true); + assertValidBytecode(result); + } + + @Test + public void blockAction_methodWithParams_producesValidBytecode() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "init", + "(Ljava/lang/String;Ljava/lang/String;)V", + "test_sdk", ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "init", + "(Ljava/lang/String;Ljava/lang/String;)V", rule, true); + assertValidBytecode(result); + } + + @Test + public void blockAction_instanceMethod_producesValidBytecode() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "start", "()V", "test_sdk", + ConsentTransformRules.Action.BLOCK); + + byte[] result = transformMethod("com/test/Sdk", "start", "()V", rule, false); + assertValidBytecode(result); + } + + // ---- THROW_IO_EXCEPTION action tests ---- + + @Test + public void throwAction_producesValidBytecode() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "getId", + "()Ljava/lang/Object;", + "test_sdk", ConsentTransformRules.Action.THROW_IO_EXCEPTION); + + byte[] result = transformMethod("com/test/Sdk", "getId", + "()Ljava/lang/Object;", rule, true); + assertValidBytecode(result); + } + + @Test + public void throwAction_containsAthrow() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "getId", + "()Ljava/lang/Object;", + "test_sdk", ConsentTransformRules.Action.THROW_IO_EXCEPTION); + + byte[] result = transformMethod("com/test/Sdk", "getId", + "()Ljava/lang/Object;", rule, true); + assertTrue("Should contain ATHROW", containsAthrow(result)); + } + + @Test + public void throwAction_referencesConsentManager() { + ConsentTransformRules.Rule rule = new ConsentTransformRules.Rule( + "com/test/Sdk", "getId", + "()Ljava/lang/Object;", + "test_sdk", ConsentTransformRules.Action.THROW_IO_EXCEPTION); + + byte[] result = transformMethod("com/test/Sdk", "getId", + "()Ljava/lang/Object;", rule, true); + assertTrue("Should reference ConsentManager", referencesConsentManager(result)); + } + + // ---- Real SDK signature tests ---- + // These verify the transforms produce valid bytecode for the actual SDK methods + + @Test + public void realSignature_googleAdsInitialize() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/MobileAds", + "initialize", "(Landroid/content/Context;)V"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/google/android/gms/ads/MobileAds", + "initialize", "(Landroid/content/Context;)V", rule, true); + assertValidBytecode(result); + assertTrue(referencesConsentManager(result)); + assertTrue(containsStringConstant(result, "google_ads")); + } + + @Test + public void realSignature_advertisingIdGetInfo() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/identifier/AdvertisingIdClient", + "getAdvertisingIdInfo", + "(Landroid/content/Context;)Lcom/google/android/gms/ads/identifier/AdvertisingIdClient$Info;"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/google/android/gms/ads/identifier/AdvertisingIdClient", + "getAdvertisingIdInfo", + "(Landroid/content/Context;)Lcom/google/android/gms/ads/identifier/AdvertisingIdClient$Info;", + rule, true); + assertValidBytecode(result); + assertTrue(containsAthrow(result)); + assertTrue(containsStringConstant(result, "google_ads_identifier")); + } + + @Test + public void realSignature_baseAdViewLoadAd() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/BaseAdView", + "loadAd", + "(Lcom/google/android/gms/ads/AdRequest;)V"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/google/android/gms/ads/BaseAdView", + "loadAd", + "(Lcom/google/android/gms/ads/AdRequest;)V", + rule, false); + assertValidBytecode(result); + assertTrue(containsStringConstant(result, "google_ads")); + } + + @Test + public void realSignature_adColonyConfigure() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/adcolony/sdk/AdColony", + "configure", + "(Landroid/content/Context;Lcom/adcolony/sdk/AdColonyAppOptions;Ljava/lang/String;)Z"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/adcolony/sdk/AdColony", + "configure", + "(Landroid/content/Context;Lcom/adcolony/sdk/AdColonyAppOptions;Ljava/lang/String;)Z", + rule, true); + assertValidBytecode(result); + assertTrue(containsStringConstant(result, "adcolony")); + } + + @Test + public void realSignature_appsFlyerStart() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/appsflyer/AppsFlyerLib", + "start", + "(Landroid/content/Context;Ljava/lang/String;Lcom/appsflyer/attribution/AppsFlyerRequestListener;)V"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/appsflyer/AppsFlyerLib", + "start", + "(Landroid/content/Context;Ljava/lang/String;Lcom/appsflyer/attribution/AppsFlyerRequestListener;)V", + rule, false); + assertValidBytecode(result); + assertTrue(containsStringConstant(result, "appsflyer")); + } + + @Test + public void realSignature_flurryBuild() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/flurry/android/FlurryAgent$Builder", + "build", + "(Landroid/content/Context;Ljava/lang/String;)V"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/flurry/android/FlurryAgent$Builder", + "build", + "(Landroid/content/Context;Ljava/lang/String;)V", + rule, false); + assertValidBytecode(result); + assertTrue(containsStringConstant(result, "flurry")); + } + + @Test + public void realSignature_inMobiInit() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/inmobi/sdk/InMobiSdk", + "init", + "(Landroid/content/Context;Ljava/lang/String;Lorg/json/JSONObject;Lcom/inmobi/sdk/SdkInitializationListener;)V"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/inmobi/sdk/InMobiSdk", + "init", + "(Landroid/content/Context;Ljava/lang/String;Lorg/json/JSONObject;Lcom/inmobi/sdk/SdkInitializationListener;)V", + rule, true); + assertValidBytecode(result); + assertTrue(containsStringConstant(result, "inmobi")); + } + + @Test + public void realSignature_vungleInit() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/vungle/warren/Vungle", + "init", + "(Ljava/lang/String;Landroid/content/Context;Lcom/vungle/warren/InitCallback;)V"); + assertNotNull(rule); + + byte[] result = transformMethod( + "com/vungle/warren/Vungle", + "init", + "(Ljava/lang/String;Landroid/content/Context;Lcom/vungle/warren/InitCallback;)V", + rule, true); + assertValidBytecode(result); + assertTrue(containsStringConstant(result, "vungle")); + } + + // ---- GPC header injection tests ---- + + /** + * Check that the transformed bytecode references GpcInterceptor. + */ + private boolean referencesGpcInterceptor(byte[] bytecode) { + ClassReader cr = new ClassReader(bytecode); + boolean[] found = {false}; + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String mName, String mDesc, boolean itf) { + if (owner.equals("net/kollnig/consent/standards/GpcInterceptor")) { + found[0] = true; + } + } + }; + } + }, 0); + return found[0]; + } + + // Helper to create a GPC rule with header method info + private ConsentTransformRules.Rule gpcRule(String className, String buildDesc, + String headerMethod, String headerDesc) { + return new ConsentTransformRules.Rule( + className, "build", buildDesc, "_gpc", + ConsentTransformRules.Action.INJECT_GPC_HEADER, + headerMethod, headerDesc); + } + + @Test + public void gpcAction_producesValidBytecode() { + ConsentTransformRules.Rule rule = gpcRule( + "okhttp3/Request$Builder", "()Lokhttp3/Request;", + "header", "(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;"); + byte[] result = transformMethod("okhttp3/Request$Builder", "build", + "()Lokhttp3/Request;", rule, false); + assertValidBytecode(result); + } + + @Test + public void gpcAction_referencesGpcInterceptor() { + ConsentTransformRules.Rule rule = gpcRule( + "okhttp3/Request$Builder", "()Lokhttp3/Request;", + "header", "(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;"); + byte[] result = transformMethod("okhttp3/Request$Builder", "build", + "()Lokhttp3/Request;", rule, false); + assertTrue("Should reference GpcInterceptor", referencesGpcInterceptor(result)); + } + + @Test + public void gpcAction_containsGpcHeaderString() { + ConsentTransformRules.Rule rule = gpcRule( + "okhttp3/Request$Builder", "()Lokhttp3/Request;", + "header", "(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;"); + byte[] result = transformMethod("okhttp3/Request$Builder", "build", + "()Lokhttp3/Request;", rule, false); + assertTrue("Should contain Sec-GPC string", containsStringConstant(result, "Sec-GPC")); + } + + @Test + public void gpcAction_doesNotReferenceConsentManager() { + ConsentTransformRules.Rule rule = gpcRule( + "okhttp3/Request$Builder", "()Lokhttp3/Request;", + "header", "(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/Request$Builder;"); + byte[] result = transformMethod("okhttp3/Request$Builder", "build", + "()Lokhttp3/Request;", rule, false); + assertFalse("GPC should NOT reference ConsentManager", + referencesConsentManager(result)); + } + + @Test + public void realSignature_okHttp3Build() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "okhttp3/Request$Builder", "build", "()Lokhttp3/Request;"); + assertNotNull(rule); + byte[] result = transformMethod("okhttp3/Request$Builder", "build", + "()Lokhttp3/Request;", rule, false); + assertValidBytecode(result); + assertTrue(referencesGpcInterceptor(result)); + assertTrue(containsStringConstant(result, "Sec-GPC")); + } + + @Test + public void realSignature_okHttp2Build() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/squareup/okhttp/Request$Builder", "build", + "()Lcom/squareup/okhttp/Request;"); + assertNotNull(rule); + byte[] result = transformMethod("com/squareup/okhttp/Request$Builder", "build", + "()Lcom/squareup/okhttp/Request;", rule, false); + assertValidBytecode(result); + assertTrue(referencesGpcInterceptor(result)); + assertTrue(containsStringConstant(result, "Sec-GPC")); + } + + @Test + public void realSignature_cronetBuild() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "org/chromium/net/UrlRequest$Builder", "build", + "()Lorg/chromium/net/UrlRequest;"); + assertNotNull(rule); + byte[] result = transformMethod("org/chromium/net/UrlRequest$Builder", "build", + "()Lorg/chromium/net/UrlRequest;", rule, false); + assertValidBytecode(result); + assertTrue(referencesGpcInterceptor(result)); + assertTrue(containsStringConstant(result, "Sec-GPC")); + } +} diff --git a/plugin/src/test/java/net/kollnig/consent/plugin/ConsentTransformRulesTest.java b/plugin/src/test/java/net/kollnig/consent/plugin/ConsentTransformRulesTest.java new file mode 100644 index 0000000..df6a570 --- /dev/null +++ b/plugin/src/test/java/net/kollnig/consent/plugin/ConsentTransformRulesTest.java @@ -0,0 +1,218 @@ +package net.kollnig.consent.plugin; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +/** + * Tests for ConsentTransformRules — verifies that rules correctly match + * SDK classes and methods. + */ +public class ConsentTransformRulesTest { + + @Test + public void hasRulesForGoogleAds() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/google/android/gms/ads/MobileAds")); + } + + @Test + public void hasRulesForBaseAdView() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/google/android/gms/ads/BaseAdView")); + } + + @Test + public void hasRulesForAdvertisingIdClient() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/google/android/gms/ads/identifier/AdvertisingIdClient")); + } + + @Test + public void hasRulesForAppsFlyer() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/appsflyer/AppsFlyerLib")); + } + + @Test + public void hasRulesForFlurry() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/flurry/android/FlurryAgent$Builder")); + } + + @Test + public void hasRulesForInMobi() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/inmobi/sdk/InMobiSdk")); + } + + @Test + public void hasRulesForAdColony() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/adcolony/sdk/AdColony")); + } + + @Test + public void hasRulesForVungle() { + assertTrue(ConsentTransformRules.hasRulesForClass( + "com/vungle/warren/Vungle")); + } + + @Test + public void noRulesForUnknownClass() { + assertFalse(ConsentTransformRules.hasRulesForClass( + "com/example/UnknownSdk")); + } + + @Test + public void noRulesForConsentManagerItself() { + assertFalse(ConsentTransformRules.hasRulesForClass( + "net/kollnig/consent/ConsentManager")); + } + + @Test + public void findRuleForGoogleAdsInitialize() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/MobileAds", + "initialize", + "(Landroid/content/Context;)V"); + assertNotNull(rule); + assertEquals("google_ads", rule.libraryId); + assertEquals(ConsentTransformRules.Action.BLOCK, rule.action); + } + + @Test + public void findRuleForGoogleAdsInitializeWithListener() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/MobileAds", + "initialize", + "(Landroid/content/Context;Lcom/google/android/gms/ads/initialization/OnInitializationCompleteListener;)V"); + assertNotNull(rule); + assertEquals("google_ads", rule.libraryId); + } + + @Test + public void findRuleForLoadAd() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/BaseAdView", + "loadAd", + "(Lcom/google/android/gms/ads/AdRequest;)V"); + assertNotNull(rule); + assertEquals("google_ads", rule.libraryId); + assertEquals(ConsentTransformRules.Action.BLOCK, rule.action); + } + + @Test + public void findRuleForAdvertisingId_throwsException() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/identifier/AdvertisingIdClient", + "getAdvertisingIdInfo", + "(Landroid/content/Context;)Lcom/google/android/gms/ads/identifier/AdvertisingIdClient$Info;"); + assertNotNull(rule); + assertEquals("google_ads_identifier", rule.libraryId); + assertEquals(ConsentTransformRules.Action.THROW_IO_EXCEPTION, rule.action); + } + + @Test + public void noRuleForWrongMethodName() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/MobileAds", + "nonexistent", + "(Landroid/content/Context;)V"); + assertNull(rule); + } + + @Test + public void noRuleForWrongDescriptor() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/google/android/gms/ads/MobileAds", + "initialize", + "(Ljava/lang/String;)V"); + assertNull(rule); + } + + @Test + public void googleAdsHasMultipleRules() { + List rules = ConsentTransformRules.getRulesForClass( + "com/google/android/gms/ads/MobileAds"); + assertEquals(2, rules.size()); // initialize(Context) and initialize(Context, Listener) + } + + // ---- GPC rules ---- + + @Test + public void hasRulesForOkHttp3() { + assertTrue(ConsentTransformRules.hasRulesForClass("okhttp3/Request$Builder")); + } + + @Test + public void hasRulesForOkHttp2() { + assertTrue(ConsentTransformRules.hasRulesForClass("com/squareup/okhttp/Request$Builder")); + } + + @Test + public void hasRulesForCronet() { + assertTrue(ConsentTransformRules.hasRulesForClass("org/chromium/net/UrlRequest$Builder")); + } + + @Test + public void findRuleForOkHttp3Build_gpcAction() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "okhttp3/Request$Builder", + "build", + "()Lokhttp3/Request;"); + assertNotNull(rule); + assertEquals(ConsentTransformRules.Action.INJECT_GPC_HEADER, rule.action); + assertEquals("header", rule.headerMethodName); + } + + @Test + public void findRuleForOkHttp2Build_gpcAction() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "com/squareup/okhttp/Request$Builder", + "build", + "()Lcom/squareup/okhttp/Request;"); + assertNotNull(rule); + assertEquals(ConsentTransformRules.Action.INJECT_GPC_HEADER, rule.action); + assertEquals("header", rule.headerMethodName); + } + + @Test + public void findRuleForCronetBuild_gpcAction() { + ConsentTransformRules.Rule rule = ConsentTransformRules.findRule( + "org/chromium/net/UrlRequest$Builder", + "build", + "()Lorg/chromium/net/UrlRequest;"); + assertNotNull(rule); + assertEquals(ConsentTransformRules.Action.INJECT_GPC_HEADER, rule.action); + assertEquals("addHeader", rule.headerMethodName); + } + + @Test + public void allRulesHaveNonEmptyLibraryId() { + String[] classes = { + "com/google/android/gms/ads/MobileAds", + "com/google/android/gms/ads/BaseAdView", + "com/google/android/gms/ads/identifier/AdvertisingIdClient", + "com/appsflyer/AppsFlyerLib", + "com/flurry/android/FlurryAgent$Builder", + "com/inmobi/sdk/InMobiSdk", + "com/adcolony/sdk/AdColony", + "com/vungle/warren/Vungle", + "okhttp3/Request$Builder", + "com/squareup/okhttp/Request$Builder", + "org/chromium/net/UrlRequest$Builder" + }; + + for (String cls : classes) { + List rules = ConsentTransformRules.getRulesForClass(cls); + assertFalse("No rules for " + cls, rules.isEmpty()); + for (ConsentTransformRules.Rule rule : rules) { + assertNotNull("Null libraryId for " + cls, rule.libraryId); + assertFalse("Empty libraryId for " + cls, rule.libraryId.isEmpty()); + } + } + } +} diff --git a/settings.gradle b/settings.gradle index eda4e3f..377f6bf 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,6 @@ pluginManagement { + // Include the local plugin module so it can be resolved by id + includeBuild('plugin') repositories { gradlePluginPortal() google()