@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 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 | 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
- | **`adId`** | <code>string</code> | The ad unit ID that you want to request | | 1.1.2 |
1334
- | **`isTesting`** | <code>boolean</code> | You can use test mode of ad. | <code>false</code> | 1.1.2 |
1335
- | **`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 |
1336
- | **`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 |
1337
- | **`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 |
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) {
@@ -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()));
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ququplay/admob",
3
- "version": "8.1.0",
3
+ "version": "8.2.1",
4
4
  "description": "A native plugin for AdMob",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",