Mandatory Consent for Admob User Messaging Platform

5.4k views Asked by At

I switched from the deprecated GDPR Consent Library to the new User Messaging Platform, and used the code as stated in the documentation.

I noticed that when the user clicks on Manage Options then Confirm choices, ads will stop displaying altogether (Ad failed to load, no ad config), and I can't find anyway to check if the user didn't consent to the use of personal data.

This is problematic as my app relies purely on ads, and I will be losing money if ads don't show up, so I want to make it mandatory for users to consent to the use of their personal data, otherwise the app should be unusable.

I have made a test project on Github so everyone can test this behavior. If you are not using an emulator, then you need to change the "TEST_DEVICE_ID" to yours.

How can I achieve this?

2

There are 2 answers

16
Tyler V On BEST ANSWER

The UMP writes its output to some attributes in SharedPreferences, outlined here. You can write some helper methods to query these attributes to find out what level of ad consent the user has given or whether the user is EEA or not, but you will need to look at more than just the VendorConsents string.

There are generally 5 attributes you will want to look for to determine whether ads will be served:

  • IABTCF_gdprApplies - An integer (0 or 1) indicating whether the user is in the EEA
  • IABTCF_PurposeConsents - A string of 0's and 1's up to 10 entries long indicating whether the user provided consent for the 10 different purposes
  • IABTCF_PurposeLegitimateInterests - A string of 0's and 1's up to 10 entries long indicating whether the app has legitimate interest for the 10 different purposes
  • IABTCF_VendorConsents - A string of 0s and 1s that is arbitrarily long, indicating whether a given vendor has been given consent for the previously mentioned purposes. Each vendor has an ID indicating their position in the string. For example Google's ID is 755, so if Google has been given consent then the 755th character in this string would be a "1". The full vendor list is available here.
  • IABTCF_VendorLegitimateInterests - Similar to the vendor consent string, except that it indicates if the vendor has legitimate interest for the previously indicated purposes.

Per the Google documentation here there are really only a few practical outcomes from the UMP Funding Choices form with respect to serving ads:

  1. The user clicked "Consent To All" - the strings above will be all 1's and personalized ads will be shown
  2. The user clicked "Consent To None" - no ads will be shown at all
  3. The user clicked "Manage" and selected storage consent (Purpose 1) and scrolled through the giant list of non-alphabetically listed vendors to also select "Google" - non-personalized ads will be shown
  4. The user clicked "Manage" and did anything less than the prior step (e.g. selected storage and basic ads but didn't manually select Google from the vendor list) - again, no ads will be shown at all

This is a pretty non-ideal set of options, since #3 is extremely unlikely to ever occur and #2 and #4 result in the user getting an ad-free app without paying. For all practical purposes, this has removed the "non-personalized ads" option that was in the legacy consent SDK (and the option to purchase the ad-free app) and replaced it with simply disabling ads entirely.

I've written a few helper methods to at least let you query what the user actually selected and act accordingly.

fun isGDPR(): Boolean {
    val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
    val gdpr = prefs.getInt("IABTCF_gdprApplies", 0)
    return gdpr == 1
}

fun canShowAds(): Boolean {
    val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)

    //https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details
    //https://support.google.com/admob/answer/9760862?hl=en&ref_topic=9756841

    val purposeConsent = prefs.getString("IABTCF_PurposeConsents", "") ?: ""
    val vendorConsent = prefs.getString("IABTCF_VendorConsents","") ?: ""
    val vendorLI = prefs.getString("IABTCF_VendorLegitimateInterests","") ?: ""
    val purposeLI = prefs.getString("IABTCF_PurposeLegitimateInterests","") ?: ""

    val googleId = 755
    val hasGoogleVendorConsent = hasAttribute(vendorConsent, index=googleId)
    val hasGoogleVendorLI = hasAttribute(vendorLI, index=googleId)

    // Minimum required for at least non-personalized ads
    return hasConsentFor(listOf(1), purposeConsent, hasGoogleVendorConsent)
            && hasConsentOrLegitimateInterestFor(listOf(2,7,9,10), purposeConsent, purposeLI, hasGoogleVendorConsent, hasGoogleVendorLI)

}

fun canShowPersonalizedAds(): Boolean {
    val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)

    //https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details
    //https://support.google.com/admob/answer/9760862?hl=en&ref_topic=9756841

    val purposeConsent = prefs.getString("IABTCF_PurposeConsents", "") ?: ""
    val vendorConsent = prefs.getString("IABTCF_VendorConsents","") ?: ""
    val vendorLI = prefs.getString("IABTCF_VendorLegitimateInterests","") ?: ""
    val purposeLI = prefs.getString("IABTCF_PurposeLegitimateInterests","") ?: ""

    val googleId = 755
    val hasGoogleVendorConsent = hasAttribute(vendorConsent, index=googleId)
    val hasGoogleVendorLI = hasAttribute(vendorLI, index=googleId)

    return hasConsentFor(listOf(1,3,4), purposeConsent, hasGoogleVendorConsent)
            && hasConsentOrLegitimateInterestFor(listOf(2,7,9,10), purposeConsent, purposeLI, hasGoogleVendorConsent, hasGoogleVendorLI)
}

// Check if a binary string has a "1" at position "index" (1-based)
private fun hasAttribute(input: String, index: Int): Boolean {
    return input.length >= index && input[index-1] == '1'
}

// Check if consent is given for a list of purposes
private fun hasConsentFor(purposes: List<Int>, purposeConsent: String, hasVendorConsent: Boolean): Boolean {
    return purposes.all { p -> hasAttribute(purposeConsent, p)} && hasVendorConsent
}

// Check if a vendor either has consent or legitimate interest for a list of purposes
private fun hasConsentOrLegitimateInterestFor(purposes: List<Int>, purposeConsent: String, purposeLI: String, hasVendorConsent: Boolean, hasVendorLI: Boolean): Boolean {
    return purposes.all { p ->
            (hasAttribute(purposeLI, p) && hasVendorLI) ||
            (hasAttribute(purposeConsent, p) && hasVendorConsent)
    }
}

Note PreferenceManager.getDefaultSharedPreferences is not deprecated - you just need to make sure to include the androidx import (import androidx.preference.PreferenceManager). If you include the wrong one (import android.preference.PreferenceManager), it will be marked as deprecated.

Edit: Example integration

Here is an example implementation of a ConsentHelper method for managing calling the UMP SDK and handling the results. This would be called on app load (e.g. in the activity onCreate) with

ConsentHelper.obtainConsentAndShow(activity) {
    // add your code to load ads here
}

This handles waiting to initialize the MobileAds SDK until after obtaining consent, and then uses a callback to begin loading ads after the consent workflow is complete.

object ConsentHelper {
    private var isMobileAdsInitializeCalled = AtomicBoolean(false)
    private var showingForm = false
    private var showingWarning = false

    private fun initializeMobileAdsSdk(context: Context) {
        if (isMobileAdsInitializeCalled.getAndSet(true)) {
            return
        }

        // Initialize the Google Mobile Ads SDK.
        MobileAds.initialize(context)
    }

    // Called from app settings to determine whether to 
    // show a button so the user can launch the dialog
    fun isUpdateConsentButtonRequired(context: Context) : Boolean {
        val consentInformation = UserMessagingPlatform.getConsentInformation(context)
        return consentInformation.privacyOptionsRequirementStatus ==
                ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED
    }

    // Called when the user clicks the button to launch
    // the CMP dialog and change their selections
    fun updateConsent(context: Activity) {
        UserMessagingPlatform.showPrivacyOptionsForm(context) { error ->
            val ci = UserMessagingPlatform.getConsentInformation(context)
            handleConsentResult(context, ci, loadAds = {})
        }
    }

    // Called from onCreate or on app load somewhere
    fun obtainConsentAndShow(context: AppCompatActivity, loadAds: ()->Unit) {

        val params = if( BuildConfig.DEBUG ) {
            val debugSettings = ConsentDebugSettings.Builder(context)
                .setDebugGeography(ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_EEA)
                .addTestDeviceHashedId("YOUR_DEVICE_ID") // Get ID from Logcat
                .build()
            ConsentRequestParameters
                .Builder()
                .setTagForUnderAgeOfConsent(false)
                .setConsentDebugSettings(debugSettings)
                .build()
        }
        else {
            ConsentRequestParameters
                .Builder()
                .setTagForUnderAgeOfConsent(false)
                .build()
        }

        val ci = UserMessagingPlatform.getConsentInformation(context)
        ci.requestConsentInfoUpdate(
            context,
            params,
            {   // Load and show the consent form. Add guard to prevent showing form more than once at a time.
                if( showingForm ) return@requestConsentInfoUpdate

                showingForm = true
                UserMessagingPlatform.loadAndShowConsentFormIfRequired(context) { error: FormError? ->
                    showingForm = false
                    handleConsentResult(context, ci, loadAds)
                }
            },
            { error ->
                // Consent gathering failed.
                Log.w("AD_HANDLER", "${error.errorCode}: ${error.message}")
            })

        // Consent has been gathered already, load ads
        if( ci.canRequestAds() ) {
            initializeMobileAdsSdk(context.applicationContext)
            loadAds()
        }
    }

    private fun handleConsentResult(context: Activity, ci: ConsentInformation, loadAds: ()->Unit) {

        // Consent has been gathered.
        if( ci.canRequestAds() ) {
            initializeMobileAdsSdk(context.applicationContext)
            logConsentChoices(context)
            loadAds()
        }
        else {
            // This is an error state - should never get here
            logConsentChoices(context)
        }
    }

    private fun logConsentChoices(context: Activity) {
        // After completing the consent workflow, check the
        // strings in SharedPreferences to see what they
        // consented to and act accordingly
        val canShow = canShowAds(context)
        val isEEA = isGDPR(context)

        // Check what level of consent the user actually provided
        println("TEST:    user consent choices")
        println("TEST:      is EEA = $isEEA")
        println("TEST:      can show ads = $canShow")
        println("TEST:      can show personalized ads = ${canShowPersonalizedAds(context)}")

        if( !isEEA ) return

        // handle user choice, activate trial mode, etc

    }
}
0
hiddeneyes02 On

I found a workaround for this, but this is no final official solution.

It seems that if a user consented to Personalized ads, a string in SharedPreferences, which key is IABTCF_VendorConsents, will contain ones and zeros corresponding to some vendors (I think). If he didn't consent, this string will be equal to 0.

private val sp = PreferenceManager.getDefaultSharedPreferences(appContext)
fun consentedToPersonalizedAds() = sp.getString("IABTCF_VendorConsents", null) != "0"