Introduction

While developing a new version of Good Mood Wallpaper App I knew I'd have to deal with obtaining a consent, in the beginning mainly for serving ads. The type of software that should help me out doing this is called a Consent Management Platform (CMP). The process of choosing the best one, especially when not having any experience so far, isn't the most straightforward. So I leaned towards finding a simple and free solution. My pick was Quantcast Choice as they offer an Android library that is compliant with version 2 of IAB TC Framework. This will allow me, if needed, to switch to some other compatible CMP as they store consent data in a standard SharedPreferences storage.

One of the other CMPs that I examined was included in Piwik PRO package which even has a free Core Plan that's good for testing out the tool. The whole package contains of a few ingredients, one for analytics, which can greatly simplify the collection of data under the user's consent. Yet, I wasn't convinced to jump onto this fleet of tools as if the app generated more events then allowed in free plan, I'd have to pay something like 600$ a month which isn't exactly something I'd like to do at the moment.

Foundation for Quantcast Choice for Android in Capacitor app

As I already promised in the title, the new version of my app is being developed using Capacitor. It's a natural and well-maintained descendant of Cordova which I used to build the first version of my app.

Because developing custom native features in Capacitor revolves around plugins, I had to build a plugin that can retrieve the data that CMP stores.

But first things first - I had to follow the Quantcast's guide to integrate their library. And frankly speeking, it's limited and not very detailed.

After downloading SDK from the logged in section of Quantcast, I had to include it in app module's build.gradle:

implementation files('libs/Choice-Mobile.aar')
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version

First thing to do is to rename the downloaded file to Choice-Mobile.aar and move it to the libs directory of Android Capacitor project.

$kotlin_version and $lifecycle_version were undefined in case of my project by default. Since Capacitor project uses camel-cased variables in gradle files, I renamed those to $kotlinVersion and $lifecycleVersion respectively and included in project's variables.gradle:

ext {
 ...
 kotlinVersion = '1.6.10'
 lifecycleVersion = '2.3.1'
}

I played a bit with the correct version of lifecycle and in my case the project compiled with the one above.

Next thing that the guide mentions is to enable viewBinding feature. It's done in module's build.gradle again:

android {
  ...
  buildFeatures {
      viewBinding true
  }
}

That should be it for the setup part, now actual implementation!

Enabling Quantcast Choice in Capacitor app

To be able to successfully start Quantcast Choice library, the Android app's application class has to implement ChoiceCmpCallback and the actual ChoiceCmp.startChoice call has to be done in application's onCreate method.

By default, Capacitor apps don't expose an application class for immediate use. So I had to create one myself. Fortunately it's easy.

Firstly, I created a new MyApplication class in main package's namespace that extends android.app.Application, calls startChoice and implements ChoiceCmpCallback:

import android.app.Application;
import com.quantcast.choicemobile.ChoiceCmp;
import com.quantcast.choicemobile.ChoiceCmpCallback;
import com.quantcast.choicemobile.core.model.ACData;
import com.quantcast.choicemobile.core.model.TCData;
import com.quantcast.choicemobile.data.model.ChoiceStylesResources;
import com.quantcast.choicemobile.model.ChoiceError;
import com.quantcast.choicemobile.model.NonIABData;
import com.quantcast.choicemobile.model.PingReturn;


public class MyApplication extends Application implements ChoiceCmpCallback {

  @Override
  public void onCreate() {
      super.onCreate();

      ChoiceCmp.startChoice(
              this,
              "package",
              "p-string",
              this,
              new ChoiceStylesResources()
      );
  }

  public void onCmpLoaded(PingReturn info) {}

  public void onCmpUIShown(PingReturn info) {}

  public void onCmpError(ChoiceError error) {}

  public void onIABVendorConsentGiven(TCData tcData) {}

  public void onNonIABVendorConsentGiven(NonIABData nonIABData) {}

  public void onGoogleVendorConsentGiven(ACData acData) {}

  public void onCCPAConsentGiven(String consent) {}
}

The next thing for the Android platform to actually pick this up was to add android:name=".MyApplication" attribute to <application> tag in AndroidManifest.xml.

Problems with compilation :(

There were two problems, the first one fairly easy to fix. Quantcast Choice for Android uses coordinatorlayout so I had to manually add this dependency in app's build.gradle: implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"

with androidxCoordinatorLayoutVersion being defined in variables.gradle.

Unfortunately, this wasn't all that I had to do in order to compile and run the app. There was a problem with a missing AndroidX View Binding class that I didn't have luck to quickly solve.

There was one thing that I noticed that was different from what the Quantcast team suggests - the version of Google Gradle plugin, which in my top-level build.gradle was set to com.android.tools.build:gradle:4.2.1. And they suggest to have 7+ because of ViewBinding feature. I wasn't sure if my Capacitor project will work correctly if I update it myself. Also I shoudn't touch top-level gradle file.

So I searched the web a bit trying to locate the package that can provide the missing class. It wasn't easy because it's not obviously listed in any repository but fortunately adding implementation "androidx.databinding:viewbinding:7.1.2" to dependencies in module's build.gradle resolved this!

Developing a Capacitor Consent plugin - native part

I created a ConsentPlugin class in main package's namespace. Since QuantcastChoice for Android implements version 2 of TC framework, all I had to go is to read from and listen to changes made to specific keys in a SharedPreferences instance:

@CapacitorPlugin(name = "Consent")
public class ConsentPlugin extends Plugin implements SharedPreferences.OnSharedPreferenceChangeListener {

  protected SharedPreferences mPreferences;


  @Override
  public void load() {
      mPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity().getApplicationContext());

      mPreferences.registerOnSharedPreferenceChangeListener(this);
  }

  @Override
  public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
      notifyListeners("consentChanged", readAdsConsent());
  }

  @PluginMethod()
  public void getAdsConsent(PluginCall call) {
      call.resolve(readAdsConsent());
  }

  @PluginMethod()
  public void showConsentScreen(PluginCall call) {
      ChoiceCmp.forceDisplayUI(getActivity());

      call.resolve();
  }

  private JSObject readAdsConsent() {
      String tcString = mPreferences.getString("IABTCF_TCString", "");
      JSObject ret = new JSObject();

      ret.put("tcString", tcString);

      return ret;
  }
}

I also needed to expose ChoiceCmp.forceDisplayUI method so that users can access their settings from the UI.

AdMob considerations

My app aims to generate income from displayed ads, at the moment it's AdMob. I don't exclude implementing in-app payments during later phase.

AdMob specifies exactly what is the bare minimum for it to display ads:

  • Consent to Store and/or access information on a device (Purpose > 1)
  • Legitimate interest (or consent, where a publisher configures > their CMP to request it) is established for Google to:
    • Select basic ads (Purpose 2)
    • Measure ad performance (Purpose 7)
    • Apply market research to generate audience insights (Purpose 9)
    • Develop and improve products (Purpose 10)

I followed these tips rigorously to implement an insufficient consent dialog that is displayed to the user with option to either exit the app or to review his consent so that AdMob is able to serve at least basic ads.

Developing a Capacitor Consent plugin - JS fun part :)

I develop my Capacitor app using not any SPA framework, but rather a good old progressively enhanced approach with the help of Stimulus and Turbo and I'm a great advocate of using them. Maybe I'll describe it more in another post.

Now the basics. The typical Capacitor way to access native methods is to registerPlugin:

import { registerPlugin } from "@capacitor/core";

const ConsentPlugin = registerPlugin("Consent");

I also needed to parse a TC string and I found a little utility just for it that I installed and imported: import tcStringParse from "tc-string-parse";.

Google has a vendor id equal to 755 when it comes to IAB's Global Vendor List which also needs to be defined in the script: const GOOGLE = "755";.

The most important bit here is the canDisplayAds function which checks for necessary user's consents and determines whether AdMob is allowed to display ads.

And now the full implementation of my Capacitor consent plugin although it's specific to how I logically split features using Stimulus' Controllers:

import { Controller } from "@hotwired/stimulus";
import tcStringParse from "tc-string-parse";
import { registerPlugin } from "@capacitor/core";
import { App } from "@capacitor/app";

const ConsentPlugin = registerPlugin("Consent");
const GOOGLE = "755";

export default class extends Controller {
static targets = ["insufficientConsentDialog"];

tcStringParsed;


async connect() {
  if (!this.consentChangedListenerHandle) {
    this.consentChangedListenerHandle = await ConsentPlugin.addListener(
      "consentChanged", ({ tcString }) => {
        this.tcStringParsed = tcStringParse(tcString);

        if (!this.canDisplayAds()) {
          this.showInsufficientConsentDialog();
        }
      }
    );
  }

  const { tcString } = await ConsentPlugin.canDisplayAds();
  this.tcStringParsed = tcStringParse(tcString);

  if (!this.canDisplayAds()) {
    this.showInsufficientConsentDialog();
  }
}

disconnect() {
  this.consentChangedListenerHandle?.remove();
}

showConsentScreen(e) {
  ConsentPlugin.showConsentScreen();
}

exitApp() {
  App.exitApp();
}

closeInsufficientConsentDialog() {
  this.insufficientConsentDialogTarget.close();
}

showInsufficientConsentDialog() {
  this.insufficientConsentDialogTarget.showModal();
}

canDisplayAds() {
  const {
    purposeConsents,
    purposeLegitimateInterests,
    vendorConsents,
    vendorLegitimateInterests
  } = this.tcStringParsed?.core;

  if (!vendorConsents[GOOGLE]) {
    return false;
  }

  const purpose1Consent = purposeConsents["1"];

  if (!purpose1Consent) {
    return false;
  }

  const purpose2Consent = purposeConsents["2"]
    || (purposeLegitimateInterests["2"] && vendorLegitimateInterests[GOOGLE]);

  if (!purpose2Consent) {
    return false;
  }

  const purpose7Consent = purposeConsents["7"]
    || (purposeLegitimateInterests["7"] && vendorLegitimateInterests[GOOGLE]);

  if (!purpose7Consent) {
    return false;
  }

  const purpose9Consent = purposeConsents["9"]
    || (purposeLegitimateInterests["9"] && vendorLegitimateInterests[GOOGLE]);

  if (!purpose9Consent) {
    return false;
  }

  const purpose10Consent = purposeConsents["10"]
    || (purposeLegitimateInterests["10"] && vendorLegitimateInterests[GOOGLE]);

  if (!purpose10Consent) {
    return false;
  }

  return true;
}
}

Other things to note are the ability to call showConsentScreen, which needs to be available for users of the app and insufficientConsentDialog which is a DOM dialog element that is displayed as modal when the plugin detects that user refused to display any ads, even by mistake.

Conclusion

It's been a rough and long learning path until I reached my goal. But it was worth it as now I understand more how CMP works, what are the consent types and how the app can check what's been given and what's not. So it's a win for me :)