Kivyで作ったAndroidアプリにAdMobを入れたので、Google UMPを使ってGDPRに対応する、の巻

Kivyで作ったAndroidアプリにAdMobを入れたので、Google UMPを使ってGDPRに対応する、の巻

はじめに

PythonのGUIフレームワークであるKivyで作ったAndroidアプリにKivMobを入れてAdMobの広告が表示できるようにしましたよ、という記事を公開しました。

Kivyで作ったAndroidアプリにKivMobでAdMobの広告を表示したときの試行錯誤、の巻

今回は上記の記事の続きで、アプリをEuropean Economic Area(EEA)圏内で配信する際に必要なGeneral Data Protection Regulation(GDPR)対応のために、GoogleのUser Messaging Platform (UMP) SDKを実装したときのメモです。

ざっくり説明

・ユーザーがEEA圏内の場合は初回起動時にUMPの同意フォームを表示します。

case1; consent obtained

同意が得られれば、広告を表示できるようにします。また、2回目起動時以降は同意フォームを表示せずに広告を表示するようにします。

case2; consent Not obtained

同意を得られなかった場合は、広告を非表示にします(広告のrequestやloadも無効にします)。

case 3; Consent on the condition that non-personalized ads can be displayed

同意フォームが表示されたときに、Manage optionsにてユーザー自らが種々の条件を設定・同意して、non-personalized広告を表示できる条件(*)を満たした場合、non-personalized広告を表示します(動画なし)。

ちなみに、AdMobがユーザーの同意状況からpersonalizedかnon-personalizedなのかをうまく振り分けて広告配信をやってくれる(*)そうな。

*参考ページ
パブリッシャー様による IAB TCF v2.0 の組み込み

・ユーザーがEEA圏外の時はGDPR対象外なので同意フォームを表示しません。

case 4; outside the scope of GDPR

この場合はそのまま広告が表示されます。(動画なし)

——–

はい。ざっくりな説明は以上です

コードは記事の最後のほうに記載していますので、ご興味ございましたらライセンス遵守下でご自由にご利用ください。

なお、GDPRを完全遵守している方法であることをワタシは保証できませんので、コードをご利用なさる場合は自己責任でお願いします。

コード概要

Kivyで作ったAndroidアプリでGDPR対応のためにUMPを実装したときのコードの概要を記載します。

(ここではAdMobの設定やUMPで表示するメッセージ等の設定などは、公式含めていろいろなところで説明されているので割愛します。)

androidconsent.pyの説明

Kivyで作ったAndroidアプリでGoogleの同意ポリシーを実装するために使用するコードです。

ユーザーに対して広告を表示するかどうかを判定し、広告の表示方法を制御します。

main.pyと同じディレクトリに置いておいて、importして使います。

—2023.7.12追記ここから—

import androidconsentをコードの冒頭に配置すると、KivMobの準備が間に合わず、広告が正しく表示されない場合があることがわかりました。解決策として、class MainApp(App)の中でon_start関数を定義し、その中でimport androidconsentを配置することにしました。これにより、広告表示の前に必要な準備を完了させることができるようになったはず。

—2023.7.12追記ここまで—

adDisplayJudge()

ユーザーが同意を与えた/与えなかった場合に広告の表示可能状態を判定するための関数です。

SharedPreferencesを使用してユーザーの同意情報を取得し、それに基づいて広告の表示状態を設定します。

具体的には、目的やベンダーごとに同意があるかどうかを確認し、それに基づいて広告の表示方法(personalized ads, non-personalized ads, disabled)を決定します。

・setTagForUnderAgeOfConsent(False)

ユーザーの年齢制限を設定するために年齢制限を設定しています。

これは、同意取得プロセスを簡単にするだけの目的で設定しています。(子供を対象にすると処理が面倒くさそうなので。。。諦めました)

これに合わせる形で、アプリは年齢制限をかけてリリースすることにしました。

OnConsentInfoUpdateSuccessListener および OnConsentInfoUpdateFailureListener

UMPの同意情報の更新が成功/失敗した場合に呼び出されるコールバックを実装しています。

それぞれのJavaインターフェースを実装するクラスを定義し、同意情報の更新が成功した場合と失敗した場合の処理を行っています。

成功した場合は、同意フォームが利用可能(EEA圏内でGDPR対象ユーザーの場合になります(たぶん))であれば同意フォームを読み込み、利用できない場合(EEA圏外でGDPR非対象ユーザーの場合になります(たぶん))はそのまま広告表示の準備を行います。

・OnConsentFormLoadSuccessListener および OnConsentFormLoadFailureListener

同意フォームの読み込みが成功したときにonConsentFormLoadSuccess メソッドが呼び出され、

ConsentInformationConsentStatus.REQUIREDの場合は同意フォーム(Consent Form)を表示します。

また、適宜adDisplayJudge()を呼び出しています。

・ConsentDebugSettingsBuilder

これにより、開発者のテストデバイスがEEA圏外にあってもEEA圏内にあることにしてしまえるのですと。

なお、TestDeviceHashedIdは、

addTestDeviceHashedIdなしで一度ビルドし、使用するテストデバイスやエミュレータにてappを起動してから

adb logcat | grep addTestDeviceHashedId 

としたら見つけられるので、そのIDをyour-test-device-hashed-id-hereの部分に入れ込んでから再びbuildすることで設定ができます。

なお、アプリリリース時にはこの部分は必要ないのでコメントアウトしておきます。

consentDebugSettingsBuilder = ConsentDebugSettingsBuilder(context)
debugSettings = consentDebugSettingsBuilder.setDebugGeography(ConsentDebugSettingsDebugGeography.DEBUG_GEOGRAPHY_EEA).addTestDeviceHashedId('your-test-device-hashed-id-here').build()
params = consentRequestParametersBuilder.setConsentDebugSettings(debugSettings).build()

*Android StudioのDevice Managerにおいて該当Deviceで”Wipe Data”すると TestDeviceHashedIdがリセットされるので、再び起動すると別IDになってしまって広告が表示されなくなる(うろ覚え)ので注意が必要です。なお、広告が出るはずなのに出なくなってしまったなど、なんかおかしくなってしまった場合は”Cold Boot Now”を試すと治ることが多いです(少ない経験上でですが)。

main.pyの説明

・import androidconsent

広告表示をするかどうかの判定を行うために(on_start関数中で)androidconsentを読み込んでいます。

・preparekivmob関数

広告表示のリクエストやロードを行うために定義しています。EEA圏内ユーザーの場合には同意/非同意のアクション後に呼び出すことができるように独立させています。

・adsdisplaystatus変数

広告表示が可能かどうかの判定のために定義しています。

0; disable ads, 1; non-personalized ads, 2; personalized ads

として、それぞれの状態を表すことにしました。

この変数を確認することで、広告を表示するかどうかを判定しています。

例えば、

def show_banner(self):
    if platform == 'android' and self.adsdisplaystatus >= 1:
        Logger.info("kivmob_test: show_banner() fired")
        self.ads.show_banner()

としています。

buildozer.specの説明

android.gradle_dependenciesに、

com.google.android.ump:user-messaging-platform:2.0.0, androidx.preference:preference:1.2.0

を追加しています。

また、

android.meta_dataには

com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT=true

を追加しています(trackingを遅らせる設定。本番環境では必要ですが、Testの時は必要ないかもしれません。)

コード

Kivyで作ったAndroidアプリにKivMobでAdMobの広告を表示したときの試行錯誤、の巻

のコード群と合わせて(または同名のものは置き換えて)ください。

main.py

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.utils import platform
from kivy.logger import Logger

if platform == 'android':
    from kivmob_mod import KivMob, TestIds, RewardedListenerInterface

#give class the name of Otameshi 
class Otameshi(BoxLayout):
    pass

#Kivmob rewarded ads
class RewardsHandler(RewardedListenerInterface):
    def on_rewarded(self, reward_type, reward_amount):
        #print("User rewarded", "Type; ", reward_type, "Amount; ", reward_amount)
        Logger.info('kivmob_test: User rewarded Type; %s Amount; %s', reward_type, reward_amount)

        #load rewarded_ads
        App.get_running_app().ads.load_rewarded_ad(TestIds.REWARDED_VIDEO) # for test


#give class the name of ButtontestApp 
class MainApp(App):

    def __init__(self, **kwargs):
        super(MainApp, self).__init__(**kwargs)
        self.title = "kivmob_test"

        #added for judgement of ads display;  ads display status [0; disable ads, 1; non-personalized ads, 2; personalized ads]
        self.adsdisplaystatus = 0 #set 0 as default

    def build(self):

        if platform == 'android':
            from jnius import autoclass, cast, PythonJavaClass, java_method
            PythonActivity = autoclass('org.kivy.android.PythonActivity')
            AppCompatActivity = autoclass('androidx.appcompat.app.AppCompatActivity')
            self.appCompatActivity = cast("androidx.appcompat.app.AppCompatActivity", PythonActivity.mActivity)
            self.context = cast('android.content.Context', self.appCompatActivity.getApplicationContext())

            self.ads = KivMob(TestIds.APP) # for test

        # moved below code into preparekivmob function to request ads after ads display judgement
        # note that self.ads.show_banner() is executed separately
        #    #banner
        #    self.ads.new_banner(TestIds.BANNER,top_pos=False) # for test
        #    self.ads.request_banner()
        #    self.ads.show_banner()

        #    #interstitial
        #    self.ads.load_interstitial(TestIds.INTERSTITIAL) # for test

        #    #rewarded_ad
        #    self.ads.load_rewarded_ad(TestIds.REWARDED_VIDEO) # for test
        #    #Pass an instance of RewardsHandler, which inherits from RewardedAdLoadCallback4kivy, to the `set_rewarded_ad_listener` method.
        #    self.ads.set_rewarded_ad_listener(RewardsHandler())


        return Otameshi()

    #for GDPR consent
    def on_start(self):
        import androidconsent

    # added for preparation/request for ads display.
    def preparekivmob(self):
        Logger.info("kivmob_test: preparekivmob() fired")
        if platform == 'android':
            Logger.info('kivmob_test: consentInformation.getConsentStatus()@preparekivmob; %s', androidconsent.consentInformation.getConsentStatus())
            Logger.info('kivmob_test: adsdisplaystatus in preparekivmob; %s', self.adsdisplaystatus)

            #banner
            self.ads.new_banner(TestIds.BANNER,top_pos=False) #for text
            self.ads.request_banner()

            #interstitial
            self.ads.load_interstitial(TestIds.INTERSTITIAL) #for test

            #rewarded_ad
            self.ads.load_rewarded_ad(TestIds.REWARDED_VIDEO) #for test
            #Pass an instance of RewardsHandler, which inherits from RewardedAdLoadCallback4kivy, to the `set_rewarded_ad_listener` method.
            self.ads.set_rewarded_ad_listener(RewardsHandler())

    def on_resume(self):
        Logger.info("kivmob_test: on_resume()")
        if platform == 'android':
            self.load_ads()

    def load_ads(self):
        #load ads when adsdisplaystatus >= 1
        if platform == 'android' and self.adsdisplaystatus >= 1:
            Logger.info("kivmob_test: load_ads() fired")

            #banner
            self.ads.request_banner()

            #interstitial
            self.ads.load_interstitial(TestIds.INTERSTITIAL) #for test

            #rewarded_ad
            self.ads.load_rewarded_ad(TestIds.REWARDED_VIDEO) #for test

    def show_banner(self):
        #display ads when adsdisplaystatus >= 1
        if platform == 'android' and self.adsdisplaystatus >= 1:
            Logger.info("kivmob_test: show_banner() fired")
            self.ads.show_banner()

    def hide_banner(self):
        if platform == 'android':
            Logger.info("kivmob_test: hide_banner() fired")
            self.ads.hide_banner()

    def load_interstitial(self):
        if platform == 'android':
            Logger.info("kivmob_test: load_interstitial() fired")
            self.ads.load_interstitial(TestIds.INTERSTITIAL) # for test

    def show_interstitial(self):
        #display ads when adsdisplaystatus >= 1
        if platform == 'android' and self.adsdisplaystatus >= 1:
            Logger.info("kivmob_test: show_interstitial() fired")
            self.ads.show_interstitial()

    def load_rewarded_ad(self):
        if platform == 'android':
            Logger.info("kivmob_test: load_rewarded_ad() fired")
            self.ads.load_rewarded_ad(TestIds.REWARDED_VIDEO) # for test

    def show_rewarded_ad(self):
        #display ads when adsdisplaystatus >= 1
        if platform == 'android' and self.adsdisplaystatus >= 1:
            Logger.info("kivmob_test: show_rewarded_ad() fired")
            self.ads.show_rewarded_ad()

if __name__ == '__main__':
    MainApp().run()

main.kv

<Otameshi>
    BoxLayout:
        orientation:'vertical'
        size:root.size
        padding:(100,100,100,300) # padding:(left, top, right, bottom)
        Label:
            id: lab1
            text: self.text
            font_size: 80
        Button:
            size: 100, 200
            text: 'hide banner'
            pos: 0,0
            font_size: 80
            on_release:
                app.hide_banner()
        Button:
            size: 100, 200
            text: 'show banner'
            pos: 0,0
            font_size: 80
            on_release:
                app.show_banner()
        Button:
            size: 100, 200
            text: 'show interstitial'
            pos: 0,0
            font_size: 80
            on_release:
                app.show_interstitial()
        Button:
            size: 100, 200
            text: 'show rewarded video'
            pos: 0,0
            font_size: 80
            on_release:
                app.show_rewarded_ad()
        Button:
            size: 100, 200
            text: 'load ads'
            pos: 0,0
            font_size: 80
            on_press:
                root.ids['lab1'].text='osunatte'
            on_release:
                root.ids['lab1'].text='';
                app.load_ads()

androidconsent.py

from kivy.app import App
from kivy.logger import Logger
from kivy.utils import platform


if platform == "android":
    from jnius import autoclass, cast, PythonJavaClass, java_method
    from android.runnable import run_on_ui_thread
    PythonActivity = autoclass('org.kivy.android.PythonActivity')
    AppCompatActivity = autoclass('androidx.appcompat.app.AppCompatActivity')
    Logger.info('Consent: -----------')
    Logger.info("Consent: androidconsent called.")
    Bundle = autoclass("android.os.Bundle")
    ConsentForm = autoclass("com.google.android.ump.ConsentForm")
    ConsentInformation = autoclass("com.google.android.ump.ConsentInformation")
    ConsentInformationConsentStatus = autoclass("com.google.android.ump.ConsentInformation$ConsentStatus")
    ConsentRequestParameters = autoclass("com.google.android.ump.ConsentRequestParameters")
    ConsentRequestParametersBuilder = autoclass("com.google.android.ump.ConsentRequestParameters$Builder")
    FormError = autoclass("com.google.android.ump.FormError")
    UserMessagingPlatform = autoclass("com.google.android.ump.UserMessagingPlatform")
    View = autoclass("android.view.View")
    Gravity = autoclass("android.view.Gravity")
    LayoutParams = autoclass("android.view.ViewGroup$LayoutParams")
    LinearLayout = autoclass("android.widget.LinearLayout")

    #for debugsettings
    ConsentDebugSettings = autoclass('com.google.android.ump.ConsentDebugSettings')
    ConsentDebugSettingsDebugGeography = autoclass('com.google.android.ump.ConsentDebugSettings$DebugGeography')
    ConsentDebugSettingsBuilder = autoclass('com.google.android.ump.ConsentDebugSettings$Builder')

    #casting the mActivity object to an instance of AppCompatActivity. 
    appCompatActivity = cast("androidx.appcompat.app.AppCompatActivity", PythonActivity.mActivity)

    #context of App
    context = cast('android.content.Context', appCompatActivity.getApplicationContext())
    Logger.info("Consent: context generation done")

    #Get the latest consent information for initialization purposes.
    consentInformation = UserMessagingPlatform.getConsentInformation(context)
    
    #Initialize the variable to its initial state.
    consentForm = None
    formError = None

    #Ad display determination.。
    def adDisplayJudge():
        #for retrieving the values stored in SharedPreferences
        PreferenceManager = autoclass('androidx.preference.PreferenceManager')
        prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext())
        purposeConsent = prefs.getString("IABTCF_PurposeConsents", "")
        purposeLI = prefs.getString("IABTCF_PurposeLegitimateInterests","")
        vendorConsent = prefs.getString("IABTCF_VendorConsents","")
        vendorLI = prefs.getString("IABTCF_VendorLegitimateInterests","")
        gdprApplies = prefs.getInt("IABTCF_gdprApplies",0)

        Logger.info('Consent: purposeConsent; %s', purposeConsent)
        Logger.info('Consent: purposeLI; %s', purposeLI)
        Logger.info('Consent: vendorConsent; %s', vendorConsent)
        Logger.info('Consent: vendorLI; %s', vendorLI)
        Logger.info('Consent: gdprApplies; %s', gdprApplies)

        if gdprApplies == 1:
            if App.get_running_app():
                #To determine and extract the consent status (1 for consented, 0 for not consented) for Google's consent policies purposes 1 to 10 under IAB TCFv2.0, you can use a loop to iterate over the purposes and check their corresponding consent values.
                status_Cons = [i+1 for i, c in enumerate(purposeConsent) if c == '1']
                status_Leginfo = [i+1 for i, c in enumerate(purposeLI) if c == '1']
                status_vendorCons = [i+1 for i, c in enumerate(vendorConsent) if c == '1']
                status_vendorLeginfo = [i+1 for i, c in enumerate(vendorLI) if c == '1']
                Logger.info('Consent: status_Cons; %s', status_Cons)
                Logger.info('Consent: status_Leginfo; %s', status_Leginfo)
                Logger.info('Consent: status_vendorCons; %s', status_vendorCons)
                Logger.info('Consent: status_vendorLeginfo; %s', status_vendorLeginfo)

                #If the initial value is not empty (indicating consent/non-consent has been given), proceed with the evaluation.
                if vendorConsent != '':
                    #Based on the extracted results, determine the advertising display state. personalized, non-personalized, disabled
                    # ads display status [0; disable ads, 1; non-personalized ads, 2; personalized ads]
                    if all(map(status_Cons.__contains__, (1,3,4))) and all(map(status_Leginfo.__contains__, (2,7,9,10)))\
                        and 755 in status_vendorCons and 755 in status_vendorLeginfo:
                        App.get_running_app().adsdisplaystatus = 2
                        Logger.info('Consent: personalized ads will be displayed. adsdisplaystatus; %s', App.get_running_app().adsdisplaystatus)
                        Logger.info("Consent: call preparekivmob()")
                        App.get_running_app().preparekivmob()
                        #call show_banner
                        App.get_running_app().ads.show_banner()

                    elif 1 in status_Cons and all(map(status_Leginfo.__contains__, (2,7,9,10)))\
                        and 755 in status_vendorCons and 755 in status_vendorLeginfo:
                        App.get_running_app().adsdisplaystatus = 1
                        Logger.info('Consent: non-personalized ads will be displayed. adsdisplaystatus; %s', App.get_running_app().adsdisplaystatus)
                        Logger.info("Consent: call preparekivmob()")
                        App.get_running_app().preparekivmob()
                        #call show_banner
                        App.get_running_app().ads.show_banner()
                    else:
                        App.get_running_app().adsdisplaystatus = 0
                        Logger.info('Consent: ads should not be displayed. adsdisplaystatus; %s', App.get_running_app().adsdisplaystatus)

                #If the initial value is '', skip the evaluation process. 
               #Set the adsdisplaystatus to 0 and do not display the ads.
                else:
                    App.get_running_app().adsdisplaystatus = 0
                    Logger.info("Consent: consent not obtained then not call preparekivmob()")
        else:
            return

    ### for test use only from here
    ###  reset consent information 
    #consentInformation.reset()
    ### for test use only end

    # set under age as false
    consentRequestParametersBuilder = ConsentRequestParametersBuilder()
    params = consentRequestParametersBuilder.setTagForUnderAgeOfConsent(False).build()
    Logger.info("Consent: set UnderAge=False done")

    #Get the latest consent information
    consentInformation = UserMessagingPlatform.getConsentInformation(context)

    class OnConsentInfoUpdateSuccessListener(PythonJavaClass):
        __javacontext__ = 'app'
        __javainterfaces__ = ["com.google.android.ump.ConsentInformation$OnConsentInfoUpdateSuccessListener"]

        def __init__(self):
            super().__init__()

        @java_method('()V')
        def onConsentInfoUpdateSuccess(self):
            Logger.info("Consent: onConsentInfoUpdateSuccess done")
            Logger.info("Consent: consent information state was successfully updated.")
            #check if a form is available then loadForm
            Logger.info('Consent: isConsentFormAvailable(); %s @onConsentInfoUpdateSuccess', consentInformation.isConsentFormAvailable())

            #Load a form if available
            if consentInformation.isConsentFormAvailable():
                loadForm()

            # when non-GDPR users
            else:
                # case of non-GDPR
                # do preparekivmob() and ads.show_banner()
                App.get_running_app().preparekivmob()
                App.get_running_app().ads.show_banner()
                #set adsdisplaystatus to 2 then personalized ads can be displayed
                App.get_running_app().adsdisplaystatus = 2

    onConsentInfoUpdateSuccessListener = OnConsentInfoUpdateSuccessListener()

    class OnConsentInfoUpdateFailureListener(PythonJavaClass):
        __javacontext__ = 'app'
        __javainterfaces__ = ["com.google.android.ump.ConsentInformation$OnConsentInfoUpdateFailureListener"]

        def __init__(self):
            super().__init__()

        @java_method('(Lcom/google/android/ump/FormError;)V')
        def onConsentInfoUpdateFailure(self,formError):
            Logger.info("Consent: onConsentInfoUpdateFailure done")
            Logger.info("Consent: consent information state couldn't be updated!")
            Logger.info('Consent: isConsentFormAvailable(); %s @onConsentInfoUpdateFailure', consentInformation.isConsentFormAvailable())
            Logger.info('Consent: formError; %s', formError)
            #// Handle the error.

    onConsentInfoUpdateFailureListener = OnConsentInfoUpdateFailureListener()
    
    class OnConsentFormLoadSuccessListener(PythonJavaClass):
        __javacontext__ = 'app'
        __javainterfaces__ = ["com.google.android.ump.UserMessagingPlatform$OnConsentFormLoadSuccessListener"]

        def __init__(self):
            super().__init__()
            self.donotconsent = False

        @java_method(('(Lcom/google/android/ump/ConsentForm;)V'))
        def onConsentFormLoadSuccess(self,consentForm):
            Logger.info("Consent: onConsentFormLoadSuccess fired")
            Logger.info("Consent: Consent form loaded successfully!")
            consentForm = consentForm
            if consentInformation.getConsentStatus() == ConsentInformationConsentStatus.REQUIRED:
                Logger.info("Consent: ConsentStatus: REQUIRED")
                consentForm.show(appCompatActivity,
                                 onConsentFormDismissedListener)
                Logger.info("Consent: consent form show")
                adDisplayJudge()
            elif consentInformation.getConsentStatus() == ConsentInformationConsentStatus.OBTAINED:
                #call adDisplayJudge()
                Logger.info("Consent: ConsentStatus: OBTAINED")
                adDisplayJudge()

    onConsentFormLoadSuccessListener = OnConsentFormLoadSuccessListener()

    class OnConsentFormLoadFailureListener(PythonJavaClass):
        __javacontext__ = 'app'
        __javainterfaces__ = ["com.google.android.ump.UserMessagingPlatform$OnConsentFormLoadFailureListener"]

        def __init__(self):
            super().__init__()

        @java_method('(Lcom/google/android/ump/FormError;)V')
        def onConsentFormLoadFailure(self,formError):
            Logger.info("Consent: onConsentFormLoadFailure fired")
            Logger.info("Consent: consent form couldn't be loaded!")
            #// Handle the error.
            Logger.info("Consent: Consentform load failure") 
    onConsentFormLoadFailureListener = OnConsentFormLoadFailureListener()

    class OnConsentFormDismissedListener(PythonJavaClass):
        __javacontext__ = 'app'
        __javainterfaces__ = ["com.google.android.ump.ConsentForm$OnConsentFormDismissedListener"]

        def __init__(self):
            super().__init__()

        @java_method('(Lcom/google/android/ump/FormError;)V')
        def onConsentFormDismissed(self, formError):
            Logger.info("Consent: onConsentFormDismissed fired")
            onConsentFormLoadSuccessListener.donotconsent = True
            if formError is not None:
                #// Handle dismissal by reloading form.
                loadForm()
                Logger.info("Consent: onConsentFormDismissed done")
            Logger.info('Consent: onConsentFormLoadSuccessListener; %s', onConsentFormLoadSuccessListener.donotconsent)

    onConsentFormDismissedListener = OnConsentFormDismissedListener()

    #consentstatus
    Logger.info('Consent: consentInformation.getConsentStatus()_part1; %s', consentInformation.getConsentStatus())

    def loadForm():
        Logger.info('Consent: loadForm function fired')
        UserMessagingPlatform.loadConsentForm(PythonActivity.mActivity,
                                              onConsentFormLoadSuccessListener,
                                              onConsentFormLoadFailureListener
                                              )
        Logger.info('Consent: loadForm function done')


    ######  DebugSettings from here ########

    #app's behavior as though the device was located in the EEA 
    consentDebugSettingsBuilder = ConsentDebugSettingsBuilder(context)

    debugSettings = consentDebugSettingsBuilder.setDebugGeography(ConsentDebugSettingsDebugGeography.DEBUG_GEOGRAPHY_EEA).addTestDeviceHashedId('your-test-device-hashed-id-here').build()

    params = consentRequestParametersBuilder.setConsentDebugSettings(debugSettings).build()

    Logger.info("Consent: set location as in the EEA. Value; %s", ConsentDebugSettingsDebugGeography.DEBUG_GEOGRAPHY_EEA)

    ######  DebugSettings end ########

    #Get the latest consent information
    consentInformation = UserMessagingPlatform.getConsentInformation(context)
    
    #requestConsentInfoUpdate
    consentInformation.requestConsentInfoUpdate(PythonActivity.mActivity,
                                                params,
                                                onConsentInfoUpdateSuccessListener,
                                                onConsentInfoUpdateFailureListener)
    Logger.info('Consent: requestConsentInfoUpdate done')

    #consentstatus
    Logger.info('Consent: consentInformation.getConsentStatus()_part2; %s', consentInformation.getConsentStatus())


else:
    Logger.info('Consent: androidconsent called on non-android platform, then passed')


if __name__ == "__main__":
    Logger.info('Consent: consent for GDPR')

buidozer.spec

[app]

# (str) Title of your application
title = kivmob_test_w_ump

# (str) Package name
package.name = kivmob_test_w_ump

# (str) Package domain (needed for android/ios packaging)
package.domain = org.test

# (str) Source code where the main.py live
source.dir = .

# (list) Source files to include (let empty to include all the files)
source.include_exts = py,png,jpg,kv,atlas

# (list) List of inclusions using pattern matching
#source.include_patterns = assets/*,images/*.png

# (list) Source files to exclude (let empty to not exclude anything)
#source.exclude_exts = spec

# (list) List of directory to exclude (let empty to not exclude anything)
source.exclude_dirs = tests, bin, venv, .gitignore, .DS_Store, .idea, .git

# (list) List of exclusions using pattern matching
# Do not prefix with './'
#source.exclude_patterns = license,images/*/*.jpg

# (str) Application versioning (method 1)
version = 0.1

# (str) Application versioning (method 2)
# version.regex = __version__ = ['"](.*)['"]
# version.filename = %(source.dir)s/main.py

# (list) Application requirements
# comma separated e.g. requirements = sqlite3,kivy
requirements = python3,kivy,android,jnius 


# (str) Custom source folders for requirements
# Sets custom source for any requirements with recipes
# requirements.source.kivy = ../../kivy

# (str) Presplash of the application
#presplash.filename = %(source.dir)s/data/presplash.png

# (str) Icon of the application
#icon.filename = %(source.dir)s/data/icon.png

# (list) Supported orientations
# Valid options are: landscape, portrait, portrait-reverse or landscape-reverse
orientation = portrait

# (list) List of service to declare
#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY

#
# OSX Specific
#

#
# author = © Copyright Info

# change the major version of python used by the app
osx.python_version = 3

# Kivy version to use
osx.kivy_version = 1.9.1

#
# Android specific
#

# (bool) Indicate if the application should be fullscreen or not
fullscreen = 0

# (string) Presplash background color (for android toolchain)
# Supported formats are: #RRGGBB #AARRGGBB or one of the following names:
# red, blue, green, black, white, gray, cyan, magenta, yellow, lightgray,
# darkgray, grey, lightgrey, darkgrey, aqua, fuchsia, lime, maroon, navy,
# olive, purple, silver, teal.
#android.presplash_color = #FFFFFF

# (string) Presplash animation using Lottie format.
# see https://lottiefiles.com/ for examples and https://airbnb.design/lottie/
# for general documentation.
# Lottie files can be created using various tools, like Adobe After Effect or Synfig.
#android.presplash_lottie = "path/to/lottie/file.json"

# (str) Adaptive icon of the application (used if Android API level is 26+ at runtime)
#icon.adaptive_foreground.filename = %(source.dir)s/data/icon_fg.png
#icon.adaptive_background.filename = %(source.dir)s/data/icon_bg.png

# (list) Permissions
# (See https://python-for-android.readthedocs.io/en/latest/buildoptions/#build-options-1 for all the supported syntaxes and properties)
#android.permissions = android.permission.INTERNET, (name=android.permission.WRITE_EXTERNAL_STORAGE;maxSdkVersion=18)
android.permissions = INTERNET, ACCESS_NETWORK_STATE

# (list) features (adds uses-feature -tags to manifest)
#android.features = android.hardware.usb.host

# (int) Target Android API, should be as high as possible.
android.api = 33

# (int) Minimum API your APK / AAB will support.
android.minapi = 21

# (int) Android SDK version to use
android.sdk = 33

# (str) Android NDK version to use
android.ndk = 25b

# (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi.
#android.ndk_api = 21

# (str) Android NDK directory (if empty, it will be automatically downloaded.)
#android.ndk_path =

# (str) Android SDK directory (if empty, it will be automatically downloaded.)
#android.sdk_path =

# (str) ANT directory (if empty, it will be automatically downloaded.)
#android.ant_path =

# (bool) If True, then skip trying to update the Android sdk
# This can be useful to avoid excess Internet downloads or save time
# when an update is due and you just want to test/build your package
# android.skip_update = False

# (bool) If True, then automatically accept SDK license
# agreements. This is intended for automation only. If set to False,
# the default, you will be shown the license when first running
# buildozer.
# android.accept_sdk_license = False

# (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.kivy.android.PythonActivity

# (str) Full name including package path of the Java class that implements Android Activity
# use that parameter together with android.entrypoint to set custom Java class instead of PythonActivity
#android.activity_class_name = org.kivy.android.PythonActivity

# (str) Extra xml to write directly inside the <manifest> element of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML code
#android.extra_manifest_xml = ./src/android/extra_manifest.xml

# (str) Extra xml to write directly inside the <manifest><application> tag of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML arguments:
#android.extra_manifest_application_arguments = ./src/android/extra_manifest_application_arguments.xml

# (str) Full name including package path of the Java class that implements Python Service
# use that parameter to set custom Java class which extends PythonService
#android.service_class_name = org.kivy.android.PythonService

# (str) Android app theme, default is ok for Kivy-based app
# android.apptheme = "@android:style/Theme.NoTitleBar"

# (list) Pattern to whitelist for the whole project
#android.whitelist =

# (bool) If True, your application will be listed as a home app (launcher app)
# android.home_app = False

# (str) Path to a custom whitelist file
#android.whitelist_src =

# (str) Path to a custom blacklist file
#android.blacklist_src =

# (list) List of Java .jar files to add to the libs so that pyjnius can access
# their classes. Don't add jars that you do not need, since extra jars can slow
# down the build process. Allows wildcards matching, for example:
# OUYA-ODK/libs/*.jar
#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar

# (list) List of Java files to add to the android project (can be java or a
# directory containing the files)
android.add_src = ./src

# (list) Android AAR archives to add
#android.add_aars =

# (list) Put these files or directories in the apk assets directory.
# Either form may be used, and assets need not be in 'source.include_exts'.
# 1) android.add_assets = source_asset_relative_path
# 2) android.add_assets = source_asset_path:destination_asset_relative_path
#android.add_assets =

# (list) Put these files or directories in the apk res directory.
# The option may be used in three ways, the value may contain one or zero ':'
# Some examples:
# 1) A file to add to resources, legal resource names contain ['a-z','0-9','_']
# android.add_resources = my_icons/all-inclusive.png:drawable/all_inclusive.png
# 2) A directory, here  'legal_icons' must contain resources of one kind
# android.add_resources = legal_icons:drawable
# 3) A directory, here 'legal_resources' must contain one or more directories, 
# each of a resource kind:  drawable, xml, etc...
# android.add_resources = legal_resources
#android.add_resources =

# (list) Gradle dependencies to add
android.gradle_dependencies = com.google.firebase:firebase-ads:21.4.0, androidx.appcompat:appcompat:1.6.1, androidx.activity:activity:1.6.1 ,com.google.android.ump:user-messaging-platform:2.0.0, androidx.preference:preference:1.2.0

# (bool) Enable AndroidX support. Enable when 'android.gradle_dependencies'
# contains an 'androidx' package, or any package from Kotlin source.
# android.enable_androidx requires android.api >= 28
android.enable_androidx = True

# (list) add java compile options
# this can for example be necessary when importing certain java libraries using the 'android.gradle_dependencies' option
# see https://developer.android.com/studio/write/java8-support for further information
# android.add_compile_options = "sourceCompatibility = 1.8", "targetCompatibility = 1.8"

# (list) Gradle repositories to add {can be necessary for some android.gradle_dependencies}
# please enclose in double quotes 
# e.g. android.gradle_repositories = "maven { url 'https://kotlin.bintray.com/ktor' }"
#android.add_gradle_repositories =

# (list) packaging options to add 
# see https://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.PackagingOptions.html
# can be necessary to solve conflicts in gradle_dependencies
# please enclose in double quotes 
# e.g. android.add_packaging_options = "exclude 'META-INF/common.kotlin_module'", "exclude 'META-INF/*.kotlin_module'"
#android.add_packaging_options =

# (list) Java classes to add as activities to the manifest.
#android.add_activities = com.example.ExampleActivity

# (str) OUYA Console category. Should be one of GAME or APP
# If you leave this blank, OUYA support will not be enabled
#android.ouya.category = GAME

# (str) Filename of OUYA Console icon. It must be a 732x412 png image.
#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png

# (str) XML file to include as an intent filters in <activity> tag
#android.manifest.intent_filters =

# (list) Copy these files to src/main/res/xml/ (used for example with intent-filters)
#android.res_xml = PATH_TO_FILE,

# (str) launchMode to set for the main activity
#android.manifest.launch_mode = standard

# (str) screenOrientation to set for the main activity.
# Valid values can be found at https://developer.android.com/guide/topics/manifest/activity-element
#android.manifest.orientation = fullSensor

# (list) Android additional libraries to copy into libs/armeabi
#android.add_libs_armeabi = libs/android/*.so
#android.add_libs_armeabi_v7a = libs/android-v7/*.so
#android.add_libs_arm64_v8a = libs/android-v8/*.so
#android.add_libs_x86 = libs/android-x86/*.so
#android.add_libs_mips = libs/android-mips/*.so

# (bool) Indicate whether the screen should stay on
# Don't forget to add the WAKE_LOCK permission if you set this to True
#android.wakelock = False

# (list) Android application meta-data to set (key=value format)
#android.meta_data = com.google.android.gms.ads.APPLICATION_ID=ca-app-pub-3940256099942544~3347511713
android.meta_data = com.google.android.gms.ads.APPLICATION_ID=ca-app-pub-3940256099942544~3347511713,com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT=true

# (list) Android library project to add (will be added in the
# project.properties automatically.)
#android.library_references =

# (list) Android shared libraries which will be added to AndroidManifest.xml using <uses-library> tag
#android.uses_library =

# (str) Android logcat filters to use
#android.logcat_filters = *:S python:D

# (bool) Android logcat only display log for activity's pid
#android.logcat_pid_only = False

# (str) Android additional adb arguments
#android.adb_args = -H host.docker.internal

# (bool) Copy library instead of making a libpymodules.so
#android.copy_libs = 1

# (list) The Android archs to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
# In past, was `android.arch` as we weren't supporting builds for multiple archs at the same time.
android.archs = arm64-v8a, armeabi-v7a

# (int) overrides automatic versionCode computation (used in build.gradle)
# this is not the same as app version and should only be edited if you know what you're doing
# android.numeric_version = 1

# (bool) enables Android auto backup feature (Android API >=23)
android.allow_backup = True

# (str) XML file for custom backup rules (see official auto backup documentation)
# android.backup_rules =

# (str) If you need to insert variables into your AndroidManifest.xml file,
# you can do so with the manifestPlaceholders property.
# This property takes a map of key-value pairs. (via a string)
# Usage example : android.manifest_placeholders = [myCustomUrl:\"org.kivy.customurl\"]
# android.manifest_placeholders = [:]

# (bool) Skip byte compile for .py files
# android.no-byte-compile-python = False

# (str) The format used to package the app for release mode (aab or apk or aar).
# android.release_artifact = aab

# (str) The format used to package the app for debug mode (apk or aar).
# android.debug_artifact = apk

#
# Python for android (p4a) specific
#

# (str) python-for-android URL to use for checkout
#p4a.url =

# (str) python-for-android fork to use in case if p4a.url is not specified, defaults to upstream (kivy)
#p4a.fork = kivy

# (str) python-for-android branch to use, defaults to master
p4a.branch = master

# (str) python-for-android specific commit to use, defaults to HEAD, must be within p4a.branch
#p4a.commit = HEAD

# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github)
#p4a.source_dir =

# (str) The directory in which python-for-android should look for your own build recipes (if any)
#p4a.local_recipes =

# (str) Filename to the hook for p4a
#p4a.hook =

# (str) Bootstrap to use for android builds
# p4a.bootstrap = sdl2

# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask)
#p4a.port =

# Control passing the --use-setup-py vs --ignore-setup-py to p4a
# "in the future" --use-setup-py is going to be the default behaviour in p4a, right now it is not
# Setting this to false will pass --ignore-setup-py, true will pass --use-setup-py
# NOTE: this is general setuptools integration, having pyproject.toml is enough, no need to generate
# setup.py if you're using Poetry, but you need to add "toml" to source.include_exts.
#p4a.setup_py = false

# (str) extra command line arguments to pass when invoking pythonforandroid.toolchain
#p4a.extra_args =



#
# iOS specific
#

# (str) Path to a custom kivy-ios folder
#ios.kivy_ios_dir = ../kivy-ios
# Alternately, specify the URL and branch of a git checkout:
ios.kivy_ios_url = https://github.com/kivy/kivy-ios
ios.kivy_ios_branch = master

# Another platform dependency: ios-deploy
# Uncomment to use a custom checkout
#ios.ios_deploy_dir = ../ios_deploy
# Or specify URL and branch
ios.ios_deploy_url = https://github.com/phonegap/ios-deploy
ios.ios_deploy_branch = 1.12.2

# (bool) Whether or not to sign the code
ios.codesign.allowed = false

# (str) Name of the certificate to use for signing the debug version
# Get a list of available identities: buildozer ios list_identities
#ios.codesign.debug = "iPhone Developer: <lastname> <firstname> (<hexstring>)"

# (str) The development team to use for signing the debug version
#ios.codesign.development_team.debug = <hexstring>

# (str) Name of the certificate to use for signing the release version
#ios.codesign.release = %(ios.codesign.debug)s

# (str) The development team to use for signing the release version
#ios.codesign.development_team.release = <hexstring>

# (str) URL pointing to .ipa file to be installed
# This option should be defined along with `display_image_url` and `full_size_image_url` options.
#ios.manifest.app_url =

# (str) URL pointing to an icon (57x57px) to be displayed during download
# This option should be defined along with `app_url` and `full_size_image_url` options.
#ios.manifest.display_image_url =

# (str) URL pointing to a large icon (512x512px) to be used by iTunes
# This option should be defined along with `app_url` and `display_image_url` options.
#ios.manifest.full_size_image_url =


[buildozer]

# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

# (int) Display warning if buildozer is run as root (0 = False, 1 = True)
warn_on_root = 1

# (str) Path to build artifact storage, absolute or relative to spec file
# build_dir = ./.buildozer

# (str) Path to build output (i.e. .apk, .aab, .ipa) storage
# bin_dir = ./bin

#    -----------------------------------------------------------------------------
#    List as sections
#
#    You can define all the "list" as [section:key].
#    Each line will be considered as a option to the list.
#    Let's take [app] / source.exclude_patterns.
#    Instead of doing:
#
#[app]
#source.exclude_patterns = license,data/audio/*.wav,data/images/original/*
#
#    This can be translated into:
#
#[app:source.exclude_patterns]
#license
#data/audio/*.wav
#data/images/original/*
#


#    -----------------------------------------------------------------------------
#    Profiles
#
#    You can extend section / key with a profile
#    For example, you want to deploy a demo version of your application without
#    HD content. You could first change the title to add "(demo)" in the name
#    and extend the excluded directories to remove the HD content.
#
#[app@demo]
#title = My Application (demo)
#
#[app:source.exclude_patterns@demo]
#images/hd/*
#
#    Then, invoke the command line with the "demo" profile:
#
#buildozer --profile demo android debug

結果

冒頭の動画をご参照ください。

おわりに

はい、Kivyで作ったAndroidアプリにAdMobを入れたので、Google UMPを使ってGDPRに対応する、の巻でした。

これでKivyのAndroidアプリにおいて、AdMobの広告表示をEAA圏内ユーザーに向けても行うことができるようになりました。

以上、もしかしたらどなたかの役に立つかもしれないので記事にしてみました。

おしまい。

ライセンス

MIT Licenseです。

androidconsent.py, main.py, main.kv

Copyright (c) 2023 bu

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

buildozer.spec

Copyright (c) 2010-2017 Kivy Team and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

参考にしたサイトなど

Google User Messaging Platform SDK をアプリに追加する

https://developers.google.com/ad-manager/mobile-ads-sdk/android/privacy/api/reference/com/google/android/ump/package-summary

Android AdMob consent with UMP — Personalized or Non-Personalized Ads in EEA

IAB 透明性と同意のフレームワーク v2.0

パブリッシャー様による IAB TCF v2.0 の組み込み

KivMob

Pyjnius


ちょっと広告です
https://business.xserver.ne.jp/

https://www.xdomain.ne.jp/

wpX Speed / wpX

★LOLIPOP★

.tokyo

MuuMuu Domain!