@ququplay/admob 8.1.0 → 8.2.1
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/README.md +9 -8
- package/android/src/main/java/com/getcapacitor/community/admob/AdMob.java +14 -0
- package/android/src/main/java/com/getcapacitor/community/admob/consent/AdConsentExecutor.java +89 -0
- package/android/src/main/java/com/getcapacitor/community/admob/nativead/NativeAdExecutor.java +56 -1
- package/dist/docs.json +12 -0
- package/dist/esm/native-ad/native-ad-options.interface.d.ts +8 -0
- package/ios/Sources/AdMobPlugin/AdMobPlugin.swift +2 -0
- package/ios/Sources/AdMobPlugin/Consent/ConsentExecutor.swift +59 -0
- package/ios/Sources/AdMobPlugin/NativeAd/NativeAdExecutor.swift +66 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1327,14 +1327,15 @@ https://developers.google.com/android/reference/com/google/android/gms/ads/AdErr
|
|
|
1327
1327
|
|
|
1328
1328
|
#### NativeAdOptions
|
|
1329
1329
|
|
|
1330
|
-
| Prop
|
|
1331
|
-
|
|
|
1332
|
-
| **`style`**
|
|
1333
|
-
| **`
|
|
1334
|
-
| **`
|
|
1335
|
-
| **`
|
|
1336
|
-
| **`
|
|
1337
|
-
| **`
|
|
1330
|
+
| Prop | Type | Description | Default | Since |
|
|
1331
|
+
| --------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----- |
|
|
1332
|
+
| **`style`** | <code><a href="#nativeadstyle">NativeAdStyle</a></code> | Colours of the native ad strip, as `#RRGGBB` (or `#AARRGGBB`). | | 8.1.0 |
|
|
1333
|
+
| **`refreshInterval`** | <code>number</code> | Seconds the ad stays on screen before a new one replaces it. Only the time it is shown counts: hidden, or with the app in the background, it waits. The old ad stays up until the new one loads. 0, the default, keeps it until it expires after an hour. | | 8.2.0 |
|
|
1334
|
+
| **`adId`** | <code>string</code> | The ad unit ID that you want to request | | 1.1.2 |
|
|
1335
|
+
| **`isTesting`** | <code>boolean</code> | You can use test mode of ad. | <code>false</code> | 1.1.2 |
|
|
1336
|
+
| **`margin`** | <code>number</code> | Margin Banner. Default is 0px; If position is BOTTOM_CENTER, margin is be margin-bottom. If position is TOP_CENTER, margin is be margin-top. | <code>0</code> | 1.1.2 |
|
|
1337
|
+
| **`npa`** | <code>boolean</code> | The default behavior of the Google Mobile Ads SDK is to serve personalized ads. Set this to true to request Non-Personalized Ads | <code>false</code> | 1.2.0 |
|
|
1338
|
+
| **`immersiveMode`** | <code>boolean</code> | Sets a flag that controls if this interstitial or reward object will be displayed in immersive mode. Call this method before show. During show, if this flag is on and immersive mode is supported, SYSTEM_UI_FLAG_IMMERSIVE_STICKY &SYSTEM_UI_FLAG_HIDE_NAVIGATION will be turned on for interstitial or reward ad. | | 7.0.3 |
|
|
1338
1339
|
|
|
1339
1340
|
|
|
1340
1341
|
#### NativeAdStyle
|
|
@@ -72,6 +72,8 @@ public class AdMob extends Plugin {
|
|
|
72
72
|
@PluginMethod
|
|
73
73
|
public void initialize(final PluginCall call) {
|
|
74
74
|
this.setRequestConfiguration(call);
|
|
75
|
+
// The Unity Ads adapter starts Unity Ads here: give it the stored consent answer first.
|
|
76
|
+
adConsentExecutor.passConsentToUnityAds();
|
|
75
77
|
|
|
76
78
|
try {
|
|
77
79
|
MobileAds.initialize(
|
|
@@ -168,6 +170,18 @@ public class AdMob extends Plugin {
|
|
|
168
170
|
bannerExecutor.removeBanner(call);
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
@Override
|
|
174
|
+
protected void handleOnPause() {
|
|
175
|
+
super.handleOnPause();
|
|
176
|
+
nativeAdExecutor.onPause();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@Override
|
|
180
|
+
protected void handleOnResume() {
|
|
181
|
+
super.handleOnResume();
|
|
182
|
+
nativeAdExecutor.onResume();
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
// Show a native ad across the top of the screen, loading one when there is none or it expired
|
|
172
186
|
@PluginMethod
|
|
173
187
|
public void showNativeAd(final PluginCall call) {
|
package/android/src/main/java/com/getcapacitor/community/admob/consent/AdConsentExecutor.java
CHANGED
|
@@ -17,10 +17,18 @@ import com.google.android.ump.ConsentInformation;
|
|
|
17
17
|
import com.google.android.ump.ConsentRequestParameters;
|
|
18
18
|
import com.google.android.ump.UserMessagingPlatform;
|
|
19
19
|
import java.util.ArrayList;
|
|
20
|
+
import java.util.Arrays;
|
|
20
21
|
import java.util.List;
|
|
21
22
|
|
|
22
23
|
public class AdConsentExecutor extends Executor {
|
|
23
24
|
|
|
25
|
+
// Unity Ads in Google's Additional Consent list (https://storage.googleapis.com/tcfac/additional-consent-providers.csv).
|
|
26
|
+
private static final String UNITY_ADS_PROVIDER_ID = "3234";
|
|
27
|
+
|
|
28
|
+
// Consent last updated before this date (2026-10-27) may not mention Unity Ads, which joined the ad partners in
|
|
29
|
+
// October 2026. Newer consent is never asked for again on this account.
|
|
30
|
+
private static final long UNITY_ADS_CONSENT_SINCE_MILLIS = 1793059200000L;
|
|
31
|
+
|
|
24
32
|
private ConsentInformation consentInformation;
|
|
25
33
|
|
|
26
34
|
public AdConsentExecutor(
|
|
@@ -74,6 +82,7 @@ public class AdConsentExecutor extends Executor {
|
|
|
74
82
|
.remove("IABTCF_PurposeLegitimateInterests")
|
|
75
83
|
.remove("IABTCF_VendorLegitimateInterests")
|
|
76
84
|
.remove("IABTCF_gdprApplies") // Force SDK to reassess context
|
|
85
|
+
.remove("IABTCF_AddtlConsent")
|
|
77
86
|
.apply();
|
|
78
87
|
Log.w(logTag, "Successfully purged all IABTCF tracking variables from SharedPreferences.");
|
|
79
88
|
}
|
|
@@ -134,9 +143,86 @@ public class AdConsentExecutor extends Executor {
|
|
|
134
143
|
return true;
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
// 6. Consent that does not mention Unity Ads makes Unity Ads ask in its own dialog, inside an ad.
|
|
147
|
+
// Ask once more, with Unity Ads now among the ad partners.
|
|
148
|
+
long lastUpdated = lastUpdatedMillis(tcString, base64);
|
|
149
|
+
if (gdprApplies(prefs) && lastUpdated >= 0 && lastUpdated < UNITY_ADS_CONSENT_SINCE_MILLIS && unityAdsConsent(prefs) == null) {
|
|
150
|
+
clearAllTCFPreferences(prefs);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
return false;
|
|
138
155
|
}
|
|
139
156
|
|
|
157
|
+
/** The TC string's "last updated" date (characters 7 to 12), or -1 when it is unreadable. */
|
|
158
|
+
private long lastUpdatedMillis(String tcString, String base64) {
|
|
159
|
+
if (tcString.length() < 13) {
|
|
160
|
+
return -1;
|
|
161
|
+
}
|
|
162
|
+
long deciseconds = 0L;
|
|
163
|
+
for (char c : tcString.substring(7, 13).toCharArray()) {
|
|
164
|
+
int value = base64.indexOf(c);
|
|
165
|
+
if (value == -1) {
|
|
166
|
+
return -1;
|
|
167
|
+
}
|
|
168
|
+
deciseconds = deciseconds * 64 + value;
|
|
169
|
+
}
|
|
170
|
+
return deciseconds * 100;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private boolean gdprApplies(SharedPreferences prefs) {
|
|
174
|
+
try {
|
|
175
|
+
return prefs.getInt("IABTCF_gdprApplies", 0) == 1;
|
|
176
|
+
} catch (ClassCastException ex) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Unity Ads' answer in Google's Additional Consent string ("2~consented ids~dv.disclosed ids"):
|
|
183
|
+
* true or false, or null when the string does not mention Unity Ads.
|
|
184
|
+
*/
|
|
185
|
+
private Boolean unityAdsConsent(SharedPreferences prefs) {
|
|
186
|
+
String additionalConsent;
|
|
187
|
+
try {
|
|
188
|
+
additionalConsent = prefs.getString("IABTCF_AddtlConsent", "");
|
|
189
|
+
} catch (ClassCastException ex) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
String[] parts = additionalConsent == null ? new String[0] : additionalConsent.split("~");
|
|
193
|
+
if (parts.length > 1 && Arrays.asList(parts[1].split("\\.")).contains(UNITY_ADS_PROVIDER_ID)) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (parts.length > 2 && Arrays.asList(parts[2].split("\\.")).contains(UNITY_ADS_PROVIDER_ID)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Tells Unity Ads the player's answer, so it never shows its own consent dialog. The AdMob adapter passes it only
|
|
204
|
+
* when the Additional Consent string mentions Unity Ads: here not mentioned counts as no. Apps without Unity Ads
|
|
205
|
+
* skip this.
|
|
206
|
+
*/
|
|
207
|
+
public void passConsentToUnityAds() {
|
|
208
|
+
Context context = contextSupplier.get();
|
|
209
|
+
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
|
|
210
|
+
if (!gdprApplies(prefs)) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
boolean consent = Boolean.TRUE.equals(unityAdsConsent(prefs));
|
|
214
|
+
try {
|
|
215
|
+
Class<?> metaDataClass = Class.forName("com.unity3d.ads.metadata.MetaData");
|
|
216
|
+
Object metaData = metaDataClass.getConstructor(Context.class).newInstance(context.getApplicationContext());
|
|
217
|
+
metaDataClass.getMethod("set", String.class, Object.class).invoke(metaData, "gdpr.consent", consent);
|
|
218
|
+
metaDataClass.getMethod("commit").invoke(metaData);
|
|
219
|
+
} catch (ClassNotFoundException ex) {
|
|
220
|
+
// No Unity Ads in this app.
|
|
221
|
+
} catch (ReflectiveOperationException ex) {
|
|
222
|
+
Log.w(logTag, "Could not pass consent to Unity Ads", ex);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
140
226
|
public boolean canShowAds() {
|
|
141
227
|
Context context = contextSupplier.get();
|
|
142
228
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
|
|
@@ -204,6 +290,7 @@ public class AdConsentExecutor extends Executor {
|
|
|
204
290
|
// Check (and possibly purge) outdated consent before reading the
|
|
205
291
|
// remaining values, so they reflect the post-cleanup state.
|
|
206
292
|
boolean isConsentOutdated = isConsentOutdated();
|
|
293
|
+
passConsentToUnityAds();
|
|
207
294
|
|
|
208
295
|
JSObject consentInfo = new JSObject();
|
|
209
296
|
consentInfo.put("status", getConsentStatusString(consentInformation.getConsentStatus()));
|
|
@@ -236,6 +323,7 @@ public class AdConsentExecutor extends Executor {
|
|
|
236
323
|
if (formError != null) {
|
|
237
324
|
call.reject("Error when show privacy form", formError.getMessage());
|
|
238
325
|
} else {
|
|
326
|
+
passConsentToUnityAds();
|
|
239
327
|
call.resolve();
|
|
240
328
|
}
|
|
241
329
|
})
|
|
@@ -268,6 +356,7 @@ public class AdConsentExecutor extends Executor {
|
|
|
268
356
|
// Check (and possibly purge) outdated consent before reading the
|
|
269
357
|
// remaining values, so they reflect the post-cleanup state.
|
|
270
358
|
boolean isConsentOutdated = isConsentOutdated();
|
|
359
|
+
passConsentToUnityAds();
|
|
271
360
|
|
|
272
361
|
JSObject consentFormInfo = new JSObject();
|
|
273
362
|
consentFormInfo.put("status", getConsentStatusString(consentInformation.getConsentStatus()));
|
package/android/src/main/java/com/getcapacitor/community/admob/nativead/NativeAdExecutor.java
CHANGED
|
@@ -39,6 +39,8 @@ import com.google.android.gms.common.util.BiConsumer;
|
|
|
39
39
|
/**
|
|
40
40
|
* A native ad drawn as a strip across the top of the screen, over the WebView, like a top banner.
|
|
41
41
|
* Hiding keeps the loaded ad, so showing it again brings back the same ad without a new request.
|
|
42
|
+
* With a refresh interval, a new ad replaces it once it has been on screen that long in all, hidden time and time in
|
|
43
|
+
* the background not counted; the old one stays up until the new one comes.
|
|
42
44
|
*/
|
|
43
45
|
public class NativeAdExecutor extends Executor {
|
|
44
46
|
|
|
@@ -55,6 +57,17 @@ public class NativeAdExecutor extends Executor {
|
|
|
55
57
|
private long mLoadedAt;
|
|
56
58
|
private boolean mLoading;
|
|
57
59
|
private boolean mWantsVisible;
|
|
60
|
+
private boolean mPaused;
|
|
61
|
+
// What the next refresh asks for: the last show's ad unit and request.
|
|
62
|
+
private String mAdId;
|
|
63
|
+
private AdRequest mAdRequest;
|
|
64
|
+
private long mRefreshMs;
|
|
65
|
+
// How long the current ad has been on screen: the time before it was last hidden, and since when it shows.
|
|
66
|
+
private long mShownMs;
|
|
67
|
+
private long mShownSince = -1;
|
|
68
|
+
private final Runnable mRefresh = () -> {
|
|
69
|
+
if (mAdId != null) load(mAdId, mAdRequest);
|
|
70
|
+
};
|
|
58
71
|
// The status bar can change height while the ad is up (rotation, display size), so its margin follows it.
|
|
59
72
|
private final ViewTreeObserver.OnGlobalLayoutListener insetListener = () -> {
|
|
60
73
|
if (mAdView != null) applyTopInset();
|
|
@@ -76,6 +89,7 @@ public class NativeAdExecutor extends Executor {
|
|
|
76
89
|
public void showNativeAd(final PluginCall call) {
|
|
77
90
|
final AdOptions adOptions = AdOptions.getFactory().createGenericOptions(call, NATIVE_TESTER_ID);
|
|
78
91
|
final JSObject style = call.getObject("style", new JSObject());
|
|
92
|
+
final long refreshMs = Math.max(0, call.getInt("refreshInterval", 0)) * 1000L;
|
|
79
93
|
|
|
80
94
|
activitySupplier
|
|
81
95
|
.get()
|
|
@@ -89,8 +103,13 @@ public class NativeAdExecutor extends Executor {
|
|
|
89
103
|
final AdRequest adRequest = RequestHelper.createRequest(adOptions);
|
|
90
104
|
final String adId = AdViewIdHelper.getFinalAdId(adOptions, adRequest, logTag, contextSupplier.get());
|
|
91
105
|
|
|
106
|
+
mAdId = adId;
|
|
107
|
+
mAdRequest = adRequest;
|
|
108
|
+
mRefreshMs = refreshMs;
|
|
109
|
+
|
|
92
110
|
if (mNativeAd != null && adId.equals(mLoadedAdId) && !isExpired()) {
|
|
93
111
|
setVisible(true);
|
|
112
|
+
startCounting();
|
|
94
113
|
} else {
|
|
95
114
|
load(adId, adRequest);
|
|
96
115
|
}
|
|
@@ -107,6 +126,7 @@ public class NativeAdExecutor extends Executor {
|
|
|
107
126
|
.get()
|
|
108
127
|
.runOnUiThread(() -> {
|
|
109
128
|
mWantsVisible = false;
|
|
129
|
+
stopCounting();
|
|
110
130
|
if (mAdView != null) {
|
|
111
131
|
setVisible(false);
|
|
112
132
|
}
|
|
@@ -119,6 +139,7 @@ public class NativeAdExecutor extends Executor {
|
|
|
119
139
|
.get()
|
|
120
140
|
.runOnUiThread(() -> {
|
|
121
141
|
mWantsVisible = false;
|
|
142
|
+
stopCounting();
|
|
122
143
|
destroyNativeAd();
|
|
123
144
|
|
|
124
145
|
if (mAdView != null) {
|
|
@@ -151,15 +172,18 @@ public class NativeAdExecutor extends Executor {
|
|
|
151
172
|
return;
|
|
152
173
|
}
|
|
153
174
|
|
|
175
|
+
stopCounting();
|
|
154
176
|
destroyNativeAd();
|
|
155
177
|
mNativeAd = nativeAd;
|
|
156
178
|
mLoadedAdId = adId;
|
|
157
179
|
mLoadedAt = SystemClock.elapsedRealtime();
|
|
180
|
+
mShownMs = 0;
|
|
158
181
|
populate(nativeAd);
|
|
159
182
|
notifyListeners(NativeAdPluginEvents.Loaded.getWebEventName(), emptyObject);
|
|
160
183
|
|
|
161
184
|
if (mWantsVisible) {
|
|
162
185
|
setVisible(true);
|
|
186
|
+
startCounting();
|
|
163
187
|
}
|
|
164
188
|
})
|
|
165
189
|
.withAdListener(
|
|
@@ -167,14 +191,18 @@ public class NativeAdExecutor extends Executor {
|
|
|
167
191
|
@Override
|
|
168
192
|
public void onAdFailedToLoad(@NonNull LoadAdError adError) {
|
|
169
193
|
mLoading = false;
|
|
194
|
+
stopCounting();
|
|
170
195
|
|
|
171
|
-
// An expired ad may not be shown again; a fresh one keeps showing
|
|
196
|
+
// An expired ad may not be shown again; a fresh one keeps showing, and the next refresh is
|
|
197
|
+
// tried after another interval on screen.
|
|
172
198
|
if (mNativeAd != null && isExpired()) {
|
|
173
199
|
destroyNativeAd();
|
|
174
200
|
}
|
|
175
201
|
if (mNativeAd == null && mAdView != null) {
|
|
176
202
|
setVisible(false);
|
|
177
203
|
}
|
|
204
|
+
mShownMs = 0;
|
|
205
|
+
startCounting();
|
|
178
206
|
|
|
179
207
|
notifyListeners(NativeAdPluginEvents.FailedToLoad.getWebEventName(), new AdMobPluginError(adError));
|
|
180
208
|
}
|
|
@@ -195,6 +223,33 @@ public class NativeAdExecutor extends Executor {
|
|
|
195
223
|
adLoader.loadAd(adRequest);
|
|
196
224
|
}
|
|
197
225
|
|
|
226
|
+
// The app went to the background, or came back: the ad is not on screen meanwhile.
|
|
227
|
+
public void onPause() {
|
|
228
|
+
mPaused = true;
|
|
229
|
+
stopCounting();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public void onResume() {
|
|
233
|
+
mPaused = false;
|
|
234
|
+
startCounting();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Counts the time the ad is on screen and asks for a new one when it has been there for the refresh interval.
|
|
238
|
+
private void startCounting() {
|
|
239
|
+
if (mShownSince >= 0 || mRefreshMs <= 0 || mNativeAd == null || mAdView == null || !mWantsVisible || mPaused) return;
|
|
240
|
+
|
|
241
|
+
mShownSince = SystemClock.elapsedRealtime();
|
|
242
|
+
mAdView.postDelayed(mRefresh, Math.max(0, mRefreshMs - mShownMs));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private void stopCounting() {
|
|
246
|
+
if (mAdView != null) mAdView.removeCallbacks(mRefresh);
|
|
247
|
+
if (mShownSince < 0) return;
|
|
248
|
+
|
|
249
|
+
mShownMs += SystemClock.elapsedRealtime() - mShownSince;
|
|
250
|
+
mShownSince = -1;
|
|
251
|
+
}
|
|
252
|
+
|
|
198
253
|
private void ensureAdView() {
|
|
199
254
|
if (mAdView != null) return;
|
|
200
255
|
|
package/dist/docs.json
CHANGED
|
@@ -2053,6 +2053,18 @@
|
|
|
2053
2053
|
],
|
|
2054
2054
|
"type": "NativeAdStyle"
|
|
2055
2055
|
},
|
|
2056
|
+
{
|
|
2057
|
+
"name": "refreshInterval",
|
|
2058
|
+
"tags": [
|
|
2059
|
+
{
|
|
2060
|
+
"text": "8.2.0",
|
|
2061
|
+
"name": "since"
|
|
2062
|
+
}
|
|
2063
|
+
],
|
|
2064
|
+
"docs": "Seconds the ad stays on screen before a new one replaces it. Only the time it is shown counts: hidden, or with\nthe app in the background, it waits. The old ad stays up until the new one loads. 0, the default, keeps it until\nit expires after an hour.",
|
|
2065
|
+
"complexTypes": [],
|
|
2066
|
+
"type": "number | undefined"
|
|
2067
|
+
},
|
|
2056
2068
|
{
|
|
2057
2069
|
"name": "adId",
|
|
2058
2070
|
"tags": [
|
|
@@ -6,6 +6,14 @@ export interface NativeAdOptions extends AdOptions {
|
|
|
6
6
|
* @since 8.1.0
|
|
7
7
|
*/
|
|
8
8
|
style?: NativeAdStyle;
|
|
9
|
+
/**
|
|
10
|
+
* Seconds the ad stays on screen before a new one replaces it. Only the time it is shown counts: hidden, or with
|
|
11
|
+
* the app in the background, it waits. The old ad stays up until the new one loads. 0, the default, keeps it until
|
|
12
|
+
* it expires after an hour.
|
|
13
|
+
*
|
|
14
|
+
* @since 8.2.0
|
|
15
|
+
*/
|
|
16
|
+
refreshInterval?: number;
|
|
9
17
|
}
|
|
10
18
|
export interface NativeAdStyle {
|
|
11
19
|
/**
|
|
@@ -56,6 +56,8 @@ public class AdMobPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
56
56
|
self.adInterstitialExecutor.plugin = self
|
|
57
57
|
self.consentExecutor.plugin = self
|
|
58
58
|
self.setRequestConfiguration(call)
|
|
59
|
+
// The Unity Ads adapter starts Unity Ads here: give it the stored consent answer first.
|
|
60
|
+
self.consentExecutor.passConsentToUnityAds()
|
|
59
61
|
|
|
60
62
|
MobileAds.shared.start(completionHandler: nil)
|
|
61
63
|
call.resolve([:])
|
|
@@ -6,6 +6,13 @@ import UserMessagingPlatform
|
|
|
6
6
|
class ConsentExecutor: NSObject {
|
|
7
7
|
weak var plugin: AdMobPlugin?
|
|
8
8
|
|
|
9
|
+
// Unity Ads in Google's Additional Consent list (https://storage.googleapis.com/tcfac/additional-consent-providers.csv).
|
|
10
|
+
private let unityAdsProviderId = "3234"
|
|
11
|
+
|
|
12
|
+
// Consent last updated before this date (2026-10-27) may not mention Unity Ads, which joined the ad partners in
|
|
13
|
+
// October 2026. Newer consent is never asked for again on this account.
|
|
14
|
+
private let unityAdsConsentSinceMillis: Int64 = 1793059200000
|
|
15
|
+
|
|
9
16
|
func requestConsentInfo(_ call: CAPPluginCall, _ debugGeography: Int, _ testDeviceIdentifiers: [String], _ tagForUnderAgeOfConsent: Bool) {
|
|
10
17
|
let parameters = RequestParameters()
|
|
11
18
|
let debugSettings = DebugSettings()
|
|
@@ -37,6 +44,7 @@ class ConsentExecutor: NSObject {
|
|
|
37
44
|
Task {
|
|
38
45
|
do {
|
|
39
46
|
try await ConsentForm.presentPrivacyOptionsForm(from: rootViewController)
|
|
47
|
+
passConsentToUnityAds()
|
|
40
48
|
call.resolve()
|
|
41
49
|
} catch {
|
|
42
50
|
call.reject("Failed to show privacy options form: \(error.localizedDescription)")
|
|
@@ -74,6 +82,7 @@ class ConsentExecutor: NSObject {
|
|
|
74
82
|
// Check (and possibly purge) outdated consent before reading the
|
|
75
83
|
// remaining values, so they reflect the post-cleanup state.
|
|
76
84
|
let isConsentOutdated = isConsentOutdated()
|
|
85
|
+
passConsentToUnityAds()
|
|
77
86
|
|
|
78
87
|
return [
|
|
79
88
|
"status": getConsentStatusString(ConsentInformation.shared.consentStatus),
|
|
@@ -173,6 +182,7 @@ class ConsentExecutor: NSObject {
|
|
|
173
182
|
settings.removeObject(forKey: "IABTCF_PurposeLegitimateInterests")
|
|
174
183
|
settings.removeObject(forKey: "IABTCF_VendorLegitimateInterests")
|
|
175
184
|
settings.removeObject(forKey: "IABTCF_gdprApplies") // Force SDK to reassess context
|
|
185
|
+
settings.removeObject(forKey: "IABTCF_AddtlConsent")
|
|
176
186
|
NSLog("AdMob: Successfully purged all IABTCF tracking variables from UserDefaults.")
|
|
177
187
|
}
|
|
178
188
|
|
|
@@ -227,8 +237,57 @@ class ConsentExecutor: NSObject {
|
|
|
227
237
|
return true
|
|
228
238
|
}
|
|
229
239
|
|
|
240
|
+
// 6. Consent that does not mention Unity Ads makes Unity Ads ask in its own dialog, inside an ad.
|
|
241
|
+
// Ask once more, with Unity Ads now among the ad partners.
|
|
242
|
+
if let lastUpdated = lastUpdatedMillis(tcString, base64),
|
|
243
|
+
isGDPR() && lastUpdated < unityAdsConsentSinceMillis && unityAdsConsent(settings) == nil {
|
|
244
|
+
clearAllTCFPreferences(settings)
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
230
248
|
return false
|
|
231
249
|
}
|
|
250
|
+
|
|
251
|
+
/// The TC string's "last updated" date (characters 7 to 12), or nil when it is unreadable.
|
|
252
|
+
private func lastUpdatedMillis(_ tcString: String, _ base64: String) -> Int64? {
|
|
253
|
+
guard tcString.count >= 13 else {
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
var deciseconds: Int64 = 0
|
|
257
|
+
for char in tcString.dropFirst(7).prefix(6) {
|
|
258
|
+
guard let value = base64.firstIndex(of: char) else {
|
|
259
|
+
return nil
|
|
260
|
+
}
|
|
261
|
+
deciseconds = deciseconds * 64 + Int64(value.utf16Offset(in: base64))
|
|
262
|
+
}
|
|
263
|
+
return deciseconds * 100
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Unity Ads' answer in Google's Additional Consent string ("2~consented ids~dv.disclosed ids"):
|
|
267
|
+
/// true or false, or nil when the string does not mention Unity Ads.
|
|
268
|
+
private func unityAdsConsent(_ settings: UserDefaults) -> Bool? {
|
|
269
|
+
let parts = (settings.string(forKey: "IABTCF_AddtlConsent") ?? "").components(separatedBy: "~")
|
|
270
|
+
if parts.count > 1 && parts[1].components(separatedBy: ".").contains(unityAdsProviderId) {
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
if parts.count > 2 && parts[2].components(separatedBy: ".").contains(unityAdsProviderId) {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
return nil
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// Tells Unity Ads the player's answer, so it never shows its own consent dialog. The AdMob adapter passes it only
|
|
280
|
+
/// when the Additional Consent string mentions Unity Ads: here not mentioned counts as no. Apps without Unity Ads
|
|
281
|
+
/// skip this.
|
|
282
|
+
func passConsentToUnityAds() {
|
|
283
|
+
guard isGDPR(), let metaDataClass = NSClassFromString("UADSMetaData") as? NSObject.Type else {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
let consent = unityAdsConsent(UserDefaults.standard) == true
|
|
287
|
+
let metaData = metaDataClass.init()
|
|
288
|
+
_ = metaData.perform(NSSelectorFromString("set:value:"), with: "gdpr.consent", with: NSNumber(value: consent))
|
|
289
|
+
_ = metaData.perform(NSSelectorFromString("commit"))
|
|
290
|
+
}
|
|
232
291
|
func getPrivacyOptionsRequirementStatus(_ requirementStatus: PrivacyOptionsRequirementStatus) -> String {
|
|
233
292
|
switch requirementStatus {
|
|
234
293
|
case PrivacyOptionsRequirementStatus.required:
|
|
@@ -5,6 +5,8 @@ import GoogleMobileAds
|
|
|
5
5
|
/**
|
|
6
6
|
* A native ad drawn as a strip across the top of the screen, below the status bar, over the WebView like a top banner.
|
|
7
7
|
* Hiding keeps the loaded ad, so showing it again brings back the same ad without a new request.
|
|
8
|
+
* With a refresh interval, a new ad replaces it once it has been on screen that long in all, hidden time and time in
|
|
9
|
+
* the background not counted; the old one stays up until the new one comes.
|
|
8
10
|
*/
|
|
9
11
|
class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
10
12
|
weak var plugin: AdMobPlugin?
|
|
@@ -18,6 +20,16 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
18
20
|
private var loadedAdUnitID: String?
|
|
19
21
|
private var loadedAt = Date.distantPast
|
|
20
22
|
private var wantsVisible = false
|
|
23
|
+
private var inBackground = false
|
|
24
|
+
// What the next refresh asks for: the last show's ad unit and request.
|
|
25
|
+
private var adUnitID: String?
|
|
26
|
+
private var request: Request?
|
|
27
|
+
private var refreshInterval: TimeInterval = 0
|
|
28
|
+
// How long the current ad has been on screen: the time before it was last hidden, and since when it shows.
|
|
29
|
+
private var shownTime: TimeInterval = 0
|
|
30
|
+
private var shownSince: Date?
|
|
31
|
+
private var refreshTimer: Timer?
|
|
32
|
+
private var observers: [NSObjectProtocol] = []
|
|
21
33
|
|
|
22
34
|
private let iconView = UIImageView()
|
|
23
35
|
private let badgeLabel = PaddedLabel()
|
|
@@ -35,10 +47,15 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
35
47
|
|
|
36
48
|
ensureAdView(in: rootViewController.view)
|
|
37
49
|
applyStyle(call.getObject("style") ?? [:])
|
|
50
|
+
observeApp()
|
|
38
51
|
wantsVisible = true
|
|
52
|
+
self.adUnitID = adUnitID
|
|
53
|
+
self.request = request
|
|
54
|
+
refreshInterval = TimeInterval(max(0, call.getInt("refreshInterval") ?? 0))
|
|
39
55
|
|
|
40
56
|
if nativeAd != nil && loadedAdUnitID == adUnitID && !isExpired() {
|
|
41
57
|
setVisible(true)
|
|
58
|
+
startCounting()
|
|
42
59
|
} else {
|
|
43
60
|
load(adUnitID, request, rootViewController)
|
|
44
61
|
}
|
|
@@ -48,6 +65,7 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
48
65
|
|
|
49
66
|
func hideNativeAd(_ call: CAPPluginCall) {
|
|
50
67
|
wantsVisible = false
|
|
68
|
+
stopCounting()
|
|
51
69
|
if adView != nil {
|
|
52
70
|
setVisible(false)
|
|
53
71
|
}
|
|
@@ -56,6 +74,7 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
56
74
|
|
|
57
75
|
func removeNativeAd(_ call: CAPPluginCall) {
|
|
58
76
|
wantsVisible = false
|
|
77
|
+
stopCounting()
|
|
59
78
|
nativeAd = nil
|
|
60
79
|
loadedAdUnitID = nil
|
|
61
80
|
|
|
@@ -87,23 +106,28 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
87
106
|
// Removed while it loaded.
|
|
88
107
|
guard let adView = adView else { return }
|
|
89
108
|
|
|
109
|
+
stopCounting()
|
|
90
110
|
nativeAd.delegate = self
|
|
91
111
|
self.nativeAd = nativeAd
|
|
92
112
|
loadedAdUnitID = adLoader.adUnitID
|
|
93
113
|
loadedAt = Date()
|
|
114
|
+
shownTime = 0
|
|
94
115
|
populate(adView, nativeAd)
|
|
95
116
|
plugin?.notifyListeners(NativeAdPluginEvents.Loaded.rawValue, data: [:])
|
|
96
117
|
|
|
97
118
|
if wantsVisible {
|
|
98
119
|
setVisible(true)
|
|
120
|
+
startCounting()
|
|
99
121
|
}
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
func adLoader(_ adLoader: AdLoader, didFailToReceiveAdWithError error: Error) {
|
|
103
125
|
self.adLoader = nil
|
|
104
126
|
NSLog("AdMob: native ad failed to load: \(error.localizedDescription)")
|
|
127
|
+
stopCounting()
|
|
105
128
|
|
|
106
|
-
// An expired ad may not be shown again; a fresh one keeps showing
|
|
129
|
+
// An expired ad may not be shown again; a fresh one keeps showing, and the next refresh is tried after another
|
|
130
|
+
// interval on screen.
|
|
107
131
|
if nativeAd != nil && isExpired() {
|
|
108
132
|
nativeAd = nil
|
|
109
133
|
loadedAdUnitID = nil
|
|
@@ -111,6 +135,8 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
111
135
|
if nativeAd == nil && adView != nil {
|
|
112
136
|
setVisible(false)
|
|
113
137
|
}
|
|
138
|
+
shownTime = 0
|
|
139
|
+
startCounting()
|
|
114
140
|
|
|
115
141
|
plugin?.notifyListeners(NativeAdPluginEvents.FailedToLoad.rawValue, data: [
|
|
116
142
|
"code": (error as NSError).code,
|
|
@@ -126,6 +152,45 @@ class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
|
|
|
126
152
|
plugin?.notifyListeners(NativeAdPluginEvents.AdImpression.rawValue, data: [:])
|
|
127
153
|
}
|
|
128
154
|
|
|
155
|
+
// Counts the time the ad is on screen and asks for a new one when it has been there for the refresh interval.
|
|
156
|
+
private func startCounting() {
|
|
157
|
+
if shownSince != nil || refreshInterval <= 0 || nativeAd == nil || !wantsVisible || inBackground { return }
|
|
158
|
+
|
|
159
|
+
shownSince = Date()
|
|
160
|
+
refreshTimer = Timer.scheduledTimer(withTimeInterval: max(0, refreshInterval - shownTime), repeats: false) { [weak self] _ in
|
|
161
|
+
guard let self = self, let adUnitID = self.adUnitID, let request = self.request,
|
|
162
|
+
let rootViewController = self.plugin?.getRootVC() else { return }
|
|
163
|
+
|
|
164
|
+
self.load(adUnitID, request, rootViewController)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func stopCounting() {
|
|
169
|
+
refreshTimer?.invalidate()
|
|
170
|
+
refreshTimer = nil
|
|
171
|
+
guard let shownSince = shownSince else { return }
|
|
172
|
+
|
|
173
|
+
shownTime += Date().timeIntervalSince(shownSince)
|
|
174
|
+
self.shownSince = nil
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The app went to the background, or came back: the ad is not on screen meanwhile.
|
|
178
|
+
private func observeApp() {
|
|
179
|
+
if !observers.isEmpty { return }
|
|
180
|
+
|
|
181
|
+
let center = NotificationCenter.default
|
|
182
|
+
observers = [
|
|
183
|
+
center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in
|
|
184
|
+
self?.inBackground = true
|
|
185
|
+
self?.stopCounting()
|
|
186
|
+
},
|
|
187
|
+
center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in
|
|
188
|
+
self?.inBackground = false
|
|
189
|
+
self?.startCounting()
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
// Icon, "Ad" badge, headline and body, the call to action and AdChoices in the top right corner; a hairline along
|
|
130
195
|
// the bottom separates it from the screen below. The wider trailing padding keeps the button clear of AdChoices.
|
|
131
196
|
private func ensureAdView(in parent: UIView) {
|