@byvik/capacitor-ump-consent 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 byVik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,284 @@
1
- # Temporary Holding Version
1
+ # @byvik/capacitor-ump-consent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Google's User Messaging Platform (UMP) consent form for Capacitor, with the answer translated to **Consent Mode v2**.
4
+
5
+ - Standalone: it does not bundle or require an AdMob plugin, so it works with whatever ads integration you already have.
6
+ - Reads the TCF answer UMP stores and maps it to the four Consent Mode signals.
7
+ - Applies those signals to Firebase Analytics when your app includes it, and returns them either way.
8
+ - Never rejects: a network error or a form that fails to load cannot keep your app from starting.
9
+ - Android only. On iOS and web every method rejects, so guard calls with `Capacitor.getPlatform() === 'android'`.
10
+
11
+ This plugin is a tool, not legal advice. You are responsible for how your app handles consent.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @byvik/capacitor-ump-consent
17
+ npx cap sync
18
+ ```
19
+
20
+ Requires Capacitor 8.
21
+
22
+ ## Setup
23
+
24
+ 1. Create a consent message for your app in AdMob under **Privacy & messaging**.
25
+
26
+ 2. UMP finds that message through your AdMob app ID. If your ads integration has not already added it, put it inside `<application>` in `android/app/src/main/AndroidManifest.xml`:
27
+
28
+ ```xml
29
+ <meta-data
30
+ android:name="com.google.android.gms.ads.APPLICATION_ID"
31
+ android:value="ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy" />
32
+ ```
33
+
34
+ 3. If you use Firebase Analytics, deny everything by default in the same place:
35
+
36
+ ```xml
37
+ <meta-data android:name="google_analytics_default_allow_analytics_storage" android:value="false" />
38
+ <meta-data android:name="google_analytics_default_allow_ad_storage" android:value="false" />
39
+ <meta-data android:name="google_analytics_default_allow_ad_user_data" android:value="false" />
40
+ <meta-data android:name="google_analytics_default_allow_ad_personalization_signals" android:value="false" />
41
+ ```
42
+
43
+ The defaults have to live in the manifest. Firebase starts with the process, before any plugin code runs, so defaults set from JavaScript would leave a window in which the SDK is already collecting with its permissive factory settings.
44
+
45
+ ### Variables
46
+
47
+ Set these in `android/variables.gradle` to override the defaults:
48
+
49
+ - `umpVersion`: version of `com.google.android.ump:user-messaging-platform` (default: `3.1.0`)
50
+ - `firebaseAnalyticsVersion`: version of `com.google.firebase:firebase-analytics` the plugin compiles against (default: `22.1.2`). The plugin does not add Firebase to your app.
51
+
52
+ ## Usage
53
+
54
+ ```ts
55
+ import { UmpConsent } from '@byvik/capacitor-ump-consent';
56
+
57
+ // On every launch, before initializing ads.
58
+ const state = await UmpConsent.requestConsent();
59
+ if (state.canRequestAds) {
60
+ // initialize your ads SDK
61
+ }
62
+
63
+ // In your settings screen.
64
+ if (state.privacyOptionsRequired) {
65
+ showPrivacyRow(() => UmpConsent.showPrivacyOptionsForm());
66
+ }
67
+ ```
68
+
69
+ ### Consent Mode
70
+
71
+ After each request or change, the plugin reads what UMP stored and derives:
72
+
73
+ | Signal | Granted when |
74
+ | -------------------- | ------------------------------- |
75
+ | `analytics_storage` | TCF purpose 1 is consented |
76
+ | `ad_storage` | TCF purpose 1 is consented |
77
+ | `ad_user_data` | TCF purposes 1 and 7 are consented |
78
+ | `ad_personalization` | TCF purposes 3 and 4 are consented |
79
+
80
+ The three ad signals follow the mapping Google documents for TCF. TCF has no purpose for analytics; tying `analytics_storage` to purpose 1 is this plugin's choice.
81
+
82
+ Where GDPR does not apply (UMP reports that no consent is required), all four are granted. With the manifest defaults above, users outside the regulated regions would otherwise never be measured.
83
+
84
+ Until UMP knows whether the user has to be asked, for example on a first launch without network, nothing is granted and nothing is applied: your manifest defaults stay in force.
85
+
86
+ Google's SDKs can infer the three ad signals from the TC string on their own, but only when the CMP writes `IABTCF_EnableAdvertiserConsentMode = 1`. This plugin does not rely on that flag, and it also sets `analytics_storage`.
87
+
88
+ Only TCF (GDPR) messages are mapped. Other message types, such as US state regulations, are not translated to Consent Mode: for those users the four signals are granted.
89
+
90
+ The same signals are returned in `ConsentState.consentMode`. To apply them yourself, turn the automatic step off:
91
+
92
+ <docgen-config>
93
+ <!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
94
+
95
+ | Prop | Type | Description | Default | Since |
96
+ | ------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ----- |
97
+ | **`firebaseConsentMode`** | <code>boolean</code> | Apply the consent to Firebase Analytics (Consent Mode v2) every time the consent is requested or changed. Has no effect in apps that do not include Firebase Analytics. Set it to `false` if you apply Consent Mode yourself from `ConsentState.consentMode`. | <code>true</code> | 0.1.0 |
98
+
99
+ ### Examples
100
+
101
+ In `capacitor.config.json`:
102
+
103
+ ```json
104
+ {
105
+ "plugins": {
106
+ "UmpConsent": {
107
+ "firebaseConsentMode": false
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ In `capacitor.config.ts`:
114
+
115
+ ```ts
116
+ /// <reference types="@byvik/capacitor-ump-consent" />
117
+
118
+ import { CapacitorConfig } from '@capacitor/cli';
119
+
120
+ const config: CapacitorConfig = {
121
+ plugins: {
122
+ UmpConsent: {
123
+ firebaseConsentMode: false,
124
+ },
125
+ },
126
+ };
127
+
128
+ export default config;
129
+ ```
130
+
131
+ </docgen-config>
132
+
133
+ ### Testing
134
+
135
+ Run the app once, find the line UMP prints to logcat with your device's hashed ID, and pass it:
136
+
137
+ ```ts
138
+ await UmpConsent.reset();
139
+ await UmpConsent.requestConsent({
140
+ debugGeography: 'eea',
141
+ testDeviceIds: ['33BE2250B43518CCDA7DE426D04EE231'],
142
+ });
143
+ ```
144
+
145
+ ## API
146
+
147
+ <docgen-index>
148
+
149
+ * [`requestConsent(...)`](#requestconsent)
150
+ * [`showPrivacyOptionsForm()`](#showprivacyoptionsform)
151
+ * [`getConsentState()`](#getconsentstate)
152
+ * [`reset()`](#reset)
153
+ * [Interfaces](#interfaces)
154
+ * [Type Aliases](#type-aliases)
155
+
156
+ </docgen-index>
157
+
158
+ <docgen-api>
159
+ <!--Update the source file JSDoc comments and rerun docgen to update the docs below-->
160
+
161
+ ### requestConsent(...)
162
+
163
+ ```typescript
164
+ requestConsent(options?: RequestConsentOptions | undefined) => Promise<ConsentState>
165
+ ```
166
+
167
+ Update the consent information and show the consent form if it is
168
+ required. Call it on every app launch, before initializing any ads SDK.
169
+
170
+ Never rejects: when the request or the form fails it resolves with the
171
+ stored state and an `error` message.
172
+
173
+ | Param | Type |
174
+ | ------------- | ----------------------------------------------------------------------- |
175
+ | **`options`** | <code><a href="#requestconsentoptions">RequestConsentOptions</a></code> |
176
+
177
+ **Returns:** <code>Promise&lt;<a href="#consentstate">ConsentState</a>&gt;</code>
178
+
179
+ **Since:** 0.1.0
180
+
181
+ --------------------
182
+
183
+
184
+ ### showPrivacyOptionsForm()
185
+
186
+ ```typescript
187
+ showPrivacyOptionsForm() => Promise<ConsentState>
188
+ ```
189
+
190
+ Show the privacy options form so the user can change their choice.
191
+ Offer it when `privacyOptionsRequired` is `true`. Never rejects.
192
+
193
+ **Returns:** <code>Promise&lt;<a href="#consentstate">ConsentState</a>&gt;</code>
194
+
195
+ **Since:** 0.1.0
196
+
197
+ --------------------
198
+
199
+
200
+ ### getConsentState()
201
+
202
+ ```typescript
203
+ getConsentState() => Promise<ConsentState>
204
+ ```
205
+
206
+ The stored consent state, without any network request or UI.
207
+
208
+ `gdprApplies`, `purposeConsents` and `consentMode` are read from storage
209
+ and are valid from the start. `status` and `canRequestAds` come from UMP,
210
+ which reports `'unknown'` and `false` until `requestConsent()` has been
211
+ called in the current launch.
212
+
213
+ **Returns:** <code>Promise&lt;<a href="#consentstate">ConsentState</a>&gt;</code>
214
+
215
+ **Since:** 0.1.0
216
+
217
+ --------------------
218
+
219
+
220
+ ### reset()
221
+
222
+ ```typescript
223
+ reset() => Promise<void>
224
+ ```
225
+
226
+ Forget the stored consent so the form shows again. For testing only.
227
+
228
+ **Since:** 0.1.0
229
+
230
+ --------------------
231
+
232
+
233
+ ### Interfaces
234
+
235
+
236
+ #### ConsentState
237
+
238
+ | Prop | Type | Description | Since |
239
+ | ---------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
240
+ | **`canRequestAds`** | <code>boolean</code> | Whether ads can be requested. Initialize your ads SDK only when this is `true`. | 0.1.0 |
241
+ | **`status`** | <code><a href="#consentstatus">ConsentStatus</a></code> | UMP's consent status. | 0.1.0 |
242
+ | **`privacyOptionsRequired`** | <code>boolean</code> | Whether the app must offer a way to reopen the privacy options form, for example a row in its settings screen that calls `showPrivacyOptionsForm()`. | 0.1.0 |
243
+ | **`gdprApplies`** | <code>boolean</code> | Whether GDPR applies to this user, as stored by UMP in `IABTCF_gdprApplies`. | 0.1.0 |
244
+ | **`purposeConsents`** | <code>string</code> | The raw `IABTCF_PurposeConsents` string: character `n - 1` is `'1'` when TCF purpose `n` is consented. Empty when there is none. | 0.1.0 |
245
+ | **`consentMode`** | <code><a href="#consentmodesignals">ConsentModeSignals</a></code> | The answer translated to Consent Mode v2. Everything is `false` until UMP knows whether this user has to be asked. | 0.1.0 |
246
+ | **`error`** | <code>string</code> | Message of the error that interrupted the request or the form, if any. The rest of the state is still valid: it reflects what UMP has stored. | 0.1.0 |
247
+
248
+
249
+ #### ConsentModeSignals
250
+
251
+ | Prop | Type | Since |
252
+ | ----------------------- | -------------------- | ----- |
253
+ | **`analyticsStorage`** | <code>boolean</code> | 0.1.0 |
254
+ | **`adStorage`** | <code>boolean</code> | 0.1.0 |
255
+ | **`adUserData`** | <code>boolean</code> | 0.1.0 |
256
+ | **`adPersonalization`** | <code>boolean</code> | 0.1.0 |
257
+
258
+
259
+ #### RequestConsentOptions
260
+
261
+ | Prop | Type | Description | Default | Since |
262
+ | ----------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
263
+ | **`tagForUnderAgeOfConsent`** | <code>boolean</code> | Tell UMP the user is under the age of consent. | <code>false</code> | 0.1.0 |
264
+ | **`debugGeography`** | <code><a href="#debuggeography">DebugGeography</a></code> | Make a test device behave as if it were in this geography. Only applies to devices listed in `testDeviceIds`. | | 0.1.0 |
265
+ | **`testDeviceIds`** | <code>string[]</code> | Hashed IDs of the test devices. UMP prints the ID of the current device to logcat the first time consent is requested. | | 0.1.0 |
266
+
267
+
268
+ ### Type Aliases
269
+
270
+
271
+ #### ConsentStatus
272
+
273
+ <code>'unknown' | 'notRequired' | 'required' | 'obtained'</code>
274
+
275
+
276
+ #### DebugGeography
277
+
278
+ <code>'disabled' | 'eea' | 'regulatedUsState' | 'other'</code>
279
+
280
+ </docgen-api>
281
+
282
+ ## License
283
+
284
+ MIT
@@ -0,0 +1,57 @@
1
+ ext {
2
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
3
+ umpVersion = project.hasProperty('umpVersion') ? rootProject.ext.umpVersion : '3.1.0'
4
+ firebaseAnalyticsVersion = project.hasProperty('firebaseAnalyticsVersion') ? rootProject.ext.firebaseAnalyticsVersion : '22.1.2'
5
+ }
6
+
7
+ buildscript {
8
+ repositories {
9
+ google()
10
+ mavenCentral()
11
+ }
12
+ dependencies {
13
+ classpath 'com.android.tools.build:gradle:8.13.0'
14
+ }
15
+ }
16
+
17
+ apply plugin: 'com.android.library'
18
+
19
+ android {
20
+ namespace = "com.byvik.capacitor.umpconsent"
21
+ compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
22
+ defaultConfig {
23
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
24
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
25
+ versionCode 1
26
+ versionName "1.0"
27
+ }
28
+ buildTypes {
29
+ release {
30
+ minifyEnabled false
31
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
32
+ }
33
+ }
34
+ lintOptions {
35
+ abortOnError = false
36
+ }
37
+ compileOptions {
38
+ sourceCompatibility JavaVersion.VERSION_21
39
+ targetCompatibility JavaVersion.VERSION_21
40
+ }
41
+ }
42
+
43
+
44
+ repositories {
45
+ google()
46
+ mavenCentral()
47
+ }
48
+
49
+
50
+ dependencies {
51
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
52
+ implementation project(':capacitor-android')
53
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
54
+ implementation "com.google.android.ump:user-messaging-platform:$umpVersion"
55
+ // Compile-only: Consent Mode is applied only in apps that ship Firebase Analytics themselves.
56
+ compileOnly "com.google.firebase:firebase-analytics:$firebaseAnalyticsVersion"
57
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,33 @@
1
+ package com.byvik.capacitor.umpconsent;
2
+
3
+ import android.annotation.SuppressLint;
4
+ import android.content.Context;
5
+ import com.google.firebase.analytics.FirebaseAnalytics;
6
+ import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus;
7
+ import com.google.firebase.analytics.FirebaseAnalytics.ConsentType;
8
+ import java.util.EnumMap;
9
+
10
+ /**
11
+ * The only class that touches Firebase. Keeping it apart lets the plugin load in apps that do not
12
+ * ship Firebase Analytics: its types are resolved when apply() runs, not when the plugin loads.
13
+ */
14
+ final class FirebaseConsentMode {
15
+
16
+ private FirebaseConsentMode() {}
17
+
18
+ // The permissions lint asks for come from Firebase's own manifest, which is present in every
19
+ // app where this code can run at all.
20
+ @SuppressLint("MissingPermission")
21
+ static void apply(Context context, boolean analyticsStorage, boolean adStorage, boolean adUserData, boolean adPersonalization) {
22
+ EnumMap<ConsentType, ConsentStatus> consent = new EnumMap<>(ConsentType.class);
23
+ consent.put(ConsentType.ANALYTICS_STORAGE, status(analyticsStorage));
24
+ consent.put(ConsentType.AD_STORAGE, status(adStorage));
25
+ consent.put(ConsentType.AD_USER_DATA, status(adUserData));
26
+ consent.put(ConsentType.AD_PERSONALIZATION, status(adPersonalization));
27
+ FirebaseAnalytics.getInstance(context).setConsent(consent);
28
+ }
29
+
30
+ private static ConsentStatus status(boolean granted) {
31
+ return granted ? ConsentStatus.GRANTED : ConsentStatus.DENIED;
32
+ }
33
+ }
@@ -0,0 +1,285 @@
1
+ package com.byvik.capacitor.umpconsent;
2
+
3
+ import android.app.Activity;
4
+ import android.content.Context;
5
+ import android.content.SharedPreferences;
6
+ import com.getcapacitor.JSArray;
7
+ import com.getcapacitor.JSObject;
8
+ import com.getcapacitor.Plugin;
9
+ import com.getcapacitor.PluginCall;
10
+ import com.getcapacitor.PluginMethod;
11
+ import com.getcapacitor.annotation.CapacitorPlugin;
12
+ import com.google.android.ump.ConsentDebugSettings;
13
+ import com.google.android.ump.ConsentInformation;
14
+ import com.google.android.ump.ConsentRequestParameters;
15
+ import com.google.android.ump.FormError;
16
+ import com.google.android.ump.UserMessagingPlatform;
17
+
18
+ /**
19
+ * Google User Messaging Platform (UMP) consent, plus the translation of the answer into the four
20
+ * Consent Mode v2 signals.
21
+ *
22
+ * <p>Showing the form is only half of the job: UMP stores the answer in TCF format, and without
23
+ * the second half nothing tells Firebase to stop measuring a player who said no.
24
+ *
25
+ * <p>No method here rejects. Every path (network error, form that fails to load) resolves with
26
+ * the current state, so a consent failure can never keep an app from starting.
27
+ */
28
+ @CapacitorPlugin(name = "UmpConsent")
29
+ public class UmpConsentPlugin extends Plugin {
30
+
31
+ // IAB TCF v2 keys. They are an IAB standard, not a Google one: every certified CMP, UMP
32
+ // included, writes them to the app's default SharedPreferences.
33
+ private static final String TCF_PURPOSE_CONSENTS = "IABTCF_PurposeConsents";
34
+ private static final String TCF_GDPR_APPLIES = "IABTCF_gdprApplies";
35
+
36
+ private ConsentInformation consentInformation;
37
+
38
+ private ConsentInformation info() {
39
+ if (consentInformation == null) {
40
+ consentInformation = UserMessagingPlatform.getConsentInformation(getContext());
41
+ }
42
+ return consentInformation;
43
+ }
44
+
45
+ @PluginMethod
46
+ public void requestConsent(PluginCall call) {
47
+ final Activity activity = getActivity();
48
+ if (activity == null) {
49
+ call.resolve(state("Activity not available"));
50
+ return;
51
+ }
52
+
53
+ ConsentRequestParameters.Builder params = new ConsentRequestParameters.Builder().setTagForUnderAgeOfConsent(
54
+ Boolean.TRUE.equals(call.getBoolean("tagForUnderAgeOfConsent", false))
55
+ );
56
+
57
+ // Debug settings make a test device behave as if it were somewhere else. Without them the
58
+ // EEA flow cannot be tried from outside the EEA, and the bug shows up with real users.
59
+ String geography = call.getString("debugGeography");
60
+ JSArray testDeviceIds = call.getArray("testDeviceIds");
61
+ if (geography != null || testDeviceIds != null) {
62
+ ConsentDebugSettings.Builder debug = new ConsentDebugSettings.Builder(getContext());
63
+ if (geography != null) {
64
+ debug.setDebugGeography(debugGeography(geography));
65
+ }
66
+ if (testDeviceIds != null) {
67
+ for (int i = 0; i < testDeviceIds.length(); i++) {
68
+ String id = testDeviceIds.optString(i, null);
69
+ if (id != null) {
70
+ debug.addTestDeviceHashedId(id);
71
+ }
72
+ }
73
+ }
74
+ params.setConsentDebugSettings(debug.build());
75
+ }
76
+
77
+ try {
78
+ info().requestConsentInfoUpdate(
79
+ activity,
80
+ params.build(),
81
+ () ->
82
+ activity.runOnUiThread(() ->
83
+ UserMessagingPlatform.loadAndShowConsentFormIfRequired(activity, (formError) -> finish(call, formError))
84
+ ),
85
+ (requestError) -> finish(call, requestError)
86
+ );
87
+ } catch (Throwable t) {
88
+ call.resolve(state(String.valueOf(t.getMessage())));
89
+ }
90
+ }
91
+
92
+ @PluginMethod
93
+ public void showPrivacyOptionsForm(PluginCall call) {
94
+ final Activity activity = getActivity();
95
+ if (activity == null) {
96
+ call.resolve(state("Activity not available"));
97
+ return;
98
+ }
99
+ activity.runOnUiThread(() -> UserMessagingPlatform.showPrivacyOptionsForm(activity, (formError) -> finish(call, formError)));
100
+ }
101
+
102
+ @PluginMethod
103
+ public void getConsentState(PluginCall call) {
104
+ call.resolve(state(null));
105
+ }
106
+
107
+ @PluginMethod
108
+ public void reset(PluginCall call) {
109
+ try {
110
+ info().reset();
111
+ } catch (Throwable ignored) {
112
+ // Nothing to reset.
113
+ }
114
+ call.resolve();
115
+ }
116
+
117
+ // A failed form grants nothing by itself: what counts is whatever UMP has stored.
118
+ private void finish(PluginCall call, FormError error) {
119
+ applyConsentMode();
120
+ call.resolve(state(error == null ? null : error.getMessage()));
121
+ }
122
+
123
+ @ConsentDebugSettings.DebugGeography
124
+ private static int debugGeography(String value) {
125
+ switch (value) {
126
+ case "eea":
127
+ return ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_EEA;
128
+ case "regulatedUsState":
129
+ return ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_REGULATED_US_STATE;
130
+ case "other":
131
+ return ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_OTHER;
132
+ default:
133
+ return ConsentDebugSettings.DebugGeography.DEBUG_GEOGRAPHY_DISABLED;
134
+ }
135
+ }
136
+
137
+ // ---------- TCF -> Consent Mode ----------
138
+
139
+ private static final class Signals {
140
+
141
+ // False until UMP has said whether this user needs to be asked. While it is false nothing
142
+ // is granted and nothing is applied, so the app's manifest defaults stay in force.
143
+ boolean known;
144
+ boolean gdprApplies;
145
+ String purposeConsents = "";
146
+ boolean analyticsStorage;
147
+ boolean adStorage;
148
+ boolean adUserData;
149
+ boolean adPersonalization;
150
+ }
151
+
152
+ private SharedPreferences defaultPreferences() {
153
+ Context context = getContext();
154
+ return context.getSharedPreferences(context.getPackageName() + "_preferences", Context.MODE_PRIVATE);
155
+ }
156
+
157
+ private int consentStatus() {
158
+ try {
159
+ return info().getConsentStatus();
160
+ } catch (Throwable t) {
161
+ return ConsentInformation.ConsentStatus.UNKNOWN;
162
+ }
163
+ }
164
+
165
+ // The TCF string is positional: the character at n - 1 is '1' when purpose n is consented.
166
+ private static boolean purpose(String purposeConsents, int n) {
167
+ return purposeConsents.length() >= n && purposeConsents.charAt(n - 1) == '1';
168
+ }
169
+
170
+ // The mapping Google documents for TCF, plus analytics_storage, which TCF does not cover and
171
+ // is tied here to purpose 1 (store and/or access information on a device):
172
+ //
173
+ // analytics_storage <- purpose 1
174
+ // ad_storage <- purpose 1
175
+ // ad_user_data <- purposes 1 and 7
176
+ // ad_personalization <- purposes 3 and 4
177
+ //
178
+ // Where GDPR does not apply everything is granted. That is the correct reading of "no need to
179
+ // ask here": apps that default to denied in the manifest would otherwise never measure anyone
180
+ // outside the regulated regions.
181
+ private Signals signals() {
182
+ Signals s = new Signals();
183
+ SharedPreferences prefs = defaultPreferences();
184
+ int status = consentStatus();
185
+ boolean hasGdprKey = prefs.contains(TCF_GDPR_APPLIES);
186
+
187
+ s.known =
188
+ hasGdprKey || status == ConsentInformation.ConsentStatus.NOT_REQUIRED || status == ConsentInformation.ConsentStatus.OBTAINED;
189
+ if (!s.known) {
190
+ return s;
191
+ }
192
+
193
+ try {
194
+ s.gdprApplies = hasGdprKey && prefs.getInt(TCF_GDPR_APPLIES, 0) == 1;
195
+ String purposes = prefs.getString(TCF_PURPOSE_CONSENTS, "");
196
+ s.purposeConsents = purposes == null ? "" : purposes;
197
+ } catch (ClassCastException e) {
198
+ // Unreadable preferences are treated as "GDPR applies, nothing consented".
199
+ s.gdprApplies = true;
200
+ }
201
+
202
+ if (!s.gdprApplies) {
203
+ s.analyticsStorage = true;
204
+ s.adStorage = true;
205
+ s.adUserData = true;
206
+ s.adPersonalization = true;
207
+ } else {
208
+ boolean p1 = purpose(s.purposeConsents, 1);
209
+ s.analyticsStorage = p1;
210
+ s.adStorage = p1;
211
+ s.adUserData = p1 && purpose(s.purposeConsents, 7);
212
+ s.adPersonalization = purpose(s.purposeConsents, 3) && purpose(s.purposeConsents, 4);
213
+ }
214
+ return s;
215
+ }
216
+
217
+ private void applyConsentMode() {
218
+ if (!getConfig().getBoolean("firebaseConsentMode", true)) {
219
+ return;
220
+ }
221
+ Signals s = signals();
222
+ if (!s.known) {
223
+ return;
224
+ }
225
+ try {
226
+ FirebaseConsentMode.apply(getContext(), s.analyticsStorage, s.adStorage, s.adUserData, s.adPersonalization);
227
+ } catch (Throwable t) {
228
+ // Firebase Analytics is not part of this app, or refused the update. Either way the
229
+ // manifest defaults stay in force: measuring too little is recoverable, too much is not.
230
+ }
231
+ }
232
+
233
+ private JSObject state(String error) {
234
+ Signals s = signals();
235
+ ConsentInformation info = null;
236
+ try {
237
+ info = info();
238
+ } catch (Throwable ignored) {
239
+ // Reported below as the most restrictive state.
240
+ }
241
+
242
+ boolean canRequestAds = false;
243
+ boolean privacyOptionsRequired = false;
244
+ if (info != null) {
245
+ try {
246
+ canRequestAds = info.canRequestAds();
247
+ privacyOptionsRequired =
248
+ info.getPrivacyOptionsRequirementStatus() == ConsentInformation.PrivacyOptionsRequirementStatus.REQUIRED;
249
+ } catch (Throwable ignored) {
250
+ // Keep the restrictive defaults.
251
+ }
252
+ }
253
+
254
+ JSObject consentMode = new JSObject();
255
+ consentMode.put("analyticsStorage", s.analyticsStorage);
256
+ consentMode.put("adStorage", s.adStorage);
257
+ consentMode.put("adUserData", s.adUserData);
258
+ consentMode.put("adPersonalization", s.adPersonalization);
259
+
260
+ JSObject ret = new JSObject();
261
+ ret.put("canRequestAds", canRequestAds);
262
+ ret.put("status", statusName(consentStatus()));
263
+ ret.put("privacyOptionsRequired", privacyOptionsRequired);
264
+ ret.put("gdprApplies", s.gdprApplies);
265
+ ret.put("purposeConsents", s.purposeConsents);
266
+ ret.put("consentMode", consentMode);
267
+ if (error != null) {
268
+ ret.put("error", error);
269
+ }
270
+ return ret;
271
+ }
272
+
273
+ private static String statusName(int status) {
274
+ switch (status) {
275
+ case ConsentInformation.ConsentStatus.NOT_REQUIRED:
276
+ return "notRequired";
277
+ case ConsentInformation.ConsentStatus.REQUIRED:
278
+ return "required";
279
+ case ConsentInformation.ConsentStatus.OBTAINED:
280
+ return "obtained";
281
+ default:
282
+ return "unknown";
283
+ }
284
+ }
285
+ }