@capacitor-community/admob 8.2.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.
Files changed (72) hide show
  1. package/CapacitorCommunityAdmob.podspec +1 -1
  2. package/Package.swift +1 -1
  3. package/README.md +8 -7
  4. package/android/src/main/java/com/getcapacitor/community/admob/AdMob.java +73 -0
  5. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdExecutor.kt +378 -0
  6. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdFeedSessions.kt +35 -0
  7. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPlacementValue.kt +44 -0
  8. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPluginEvents.kt +11 -0
  9. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/PluginNativeAdView.kt +187 -0
  10. package/dist/esm/index.d.ts +1 -0
  11. package/dist/esm/index.js +5 -1
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/native-ads/index.d.ts +6 -0
  14. package/dist/esm/native-ads/index.js +4 -0
  15. package/dist/esm/native-ads/index.js.map +1 -0
  16. package/dist/esm/native-ads/native-ad-definitions.interface.d.ts +14 -0
  17. package/dist/esm/native-ads/native-ad-definitions.interface.js +2 -0
  18. package/dist/esm/native-ads/native-ad-definitions.interface.js.map +1 -0
  19. package/dist/esm/native-ads/native-ad-element.d.ts +9 -0
  20. package/dist/esm/native-ads/native-ad-element.js +53 -0
  21. package/dist/esm/native-ads/native-ad-element.js.map +1 -0
  22. package/dist/esm/native-ads/native-ad-event.interface.d.ts +5 -0
  23. package/dist/esm/native-ads/native-ad-event.interface.js +2 -0
  24. package/dist/esm/native-ads/native-ad-event.interface.js.map +1 -0
  25. package/dist/esm/native-ads/native-ad-feed.d.ts +72 -0
  26. package/dist/esm/native-ads/native-ad-feed.js +568 -0
  27. package/dist/esm/native-ads/native-ad-feed.js.map +1 -0
  28. package/dist/esm/native-ads/native-ad-geometry.d.ts +8 -0
  29. package/dist/esm/native-ads/native-ad-geometry.js +91 -0
  30. package/dist/esm/native-ads/native-ad-geometry.js.map +1 -0
  31. package/dist/esm/native-ads/native-ad-options.interface.d.ts +36 -0
  32. package/dist/esm/native-ads/native-ad-options.interface.js +2 -0
  33. package/dist/esm/native-ads/native-ad-options.interface.js.map +1 -0
  34. package/dist/esm/native-ads/native-ad-placement.interface.d.ts +26 -0
  35. package/dist/esm/native-ads/native-ad-placement.interface.js +2 -0
  36. package/dist/esm/native-ads/native-ad-placement.interface.js.map +1 -0
  37. package/dist/esm/native-ads/native-ad-plugin-events.enum.d.ts +9 -0
  38. package/dist/esm/native-ads/native-ad-plugin-events.enum.js +11 -0
  39. package/dist/esm/native-ads/native-ad-plugin-events.enum.js.map +1 -0
  40. package/dist/esm/native-ads/native-ad-style.interface.d.ts +30 -0
  41. package/dist/esm/native-ads/native-ad-style.interface.js +2 -0
  42. package/dist/esm/native-ads/native-ad-style.interface.js.map +1 -0
  43. package/dist/esm/native-ads/native-ad-template.enum.d.ts +11 -0
  44. package/dist/esm/native-ads/native-ad-template.enum.js +13 -0
  45. package/dist/esm/native-ads/native-ad-template.enum.js.map +1 -0
  46. package/dist/esm/web.d.ts +9 -1
  47. package/dist/esm/web.js +15 -0
  48. package/dist/esm/web.js.map +1 -1
  49. package/dist/plugin.cjs.js +751 -1
  50. package/dist/plugin.cjs.js.map +1 -1
  51. package/dist/plugin.js +751 -1
  52. package/dist/plugin.js.map +1 -1
  53. package/docs/configuration.md +2 -2
  54. package/docs/consent.md +6 -6
  55. package/docs/events.md +3 -2
  56. package/docs/migration.md +22 -2
  57. package/docs/native-ads.md +161 -0
  58. package/docs/rewarded.md +13 -0
  59. package/ios/Sources/AdMobPlugin/AdMobPlugin.swift +103 -41
  60. package/ios/Sources/AdMobPlugin/Interstitial/AdInterstitialExecutor.swift +3 -0
  61. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdExecutor.swift +354 -0
  62. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdFeedSessions.swift +34 -0
  63. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPlacementValue.swift +50 -0
  64. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPluginEvents.swift +11 -0
  65. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdScrollTracker.swift +66 -0
  66. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdValues.swift +27 -0
  67. package/ios/Sources/AdMobPlugin/NativeAds/PluginNativeAdView.swift +187 -0
  68. package/ios/Sources/AdMobPlugin/Rewarded/AdRewardExecutor.swift +3 -0
  69. package/ios/Sources/AdMobPlugin/RewardedInterstitial/AdRewardInterstitialExecutor.swift +3 -0
  70. package/ios/Tests/AdMobPluginTests/AdMobPluginTests.swift +82 -10
  71. package/ios/Tests/AdMobPluginTests/NativeAdScrollTrackerTests.swift +47 -0
  72. package/package.json +2 -1
@@ -23,6 +23,11 @@ public class AdMobPlugin: CAPPlugin, CAPBridgedPlugin {
23
23
  CAPPluginMethod(name: "resumeBanner", returnType: CAPPluginReturnPromise),
24
24
  CAPPluginMethod(name: "hideBanner", returnType: CAPPluginReturnPromise),
25
25
  CAPPluginMethod(name: "removeBanner", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "startNativeAdFeed", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "destroyNativeAdFeed", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "loadNativeAd", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "updateNativeAdPlacements", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "removeNativeAd", returnType: CAPPluginReturnPromise),
26
31
  CAPPluginMethod(name: "prepareInterstitial", returnType: CAPPluginReturnPromise),
27
32
  CAPPluginMethod(name: "showInterstitial", returnType: CAPPluginReturnPromise),
28
33
  CAPPluginMethod(name: "prepareRewardVideoAd", returnType: CAPPluginReturnPromise),
@@ -64,25 +69,40 @@ public class AdMobPlugin: CAPPlugin, CAPBridgedPlugin {
64
69
  private let adRewardExecutor = AdRewardExecutor()
65
70
  private let adRewardInterstitialExecutor = AdRewardInterstitialExecutor()
66
71
  private let consentExecutor = ConsentExecutor()
72
+ private let nativeAdExecutor = NativeAdExecutor()
67
73
 
68
74
  public override func load() {
69
75
  self.consentExecutor.plugin = self
70
76
  }
71
77
 
72
- /**
73
- * Enable SKAdNetwork to track conversions
74
- * https://developers.google.com/admob/ios/ios14
75
- */
76
- @objc func initialize(_ call: CAPPluginCall) {
77
- self.bannerExecutor.plugin = self
78
- self.adInterstitialExecutor.plugin = self
79
- self.adRewardExecutor.plugin = self
80
- self.adRewardInterstitialExecutor.plugin = self
81
- self.adInterstitialExecutor.plugin = self
82
- self.setRequestConfiguration(call)
78
+ /// Full-screen ads that currently have web media suspended.
79
+ private var webMediaSuspenders = Set<ObjectIdentifier>()
80
+
81
+ /// Pauses all web media while a full-screen ad is up, so WebKit releases its audio before the ad plays
82
+ /// and sets it up again afterwards, instead of leaving web audio silent until the app restarts.
83
+ func suspendWebMedia(for owner: AnyObject) {
84
+ Task { @MainActor [weak self] in
85
+ guard let self, self.webMediaSuspenders.insert(ObjectIdentifier(owner)).inserted,
86
+ self.webMediaSuspenders.count == 1
87
+ else { return }
88
+ self.setWebMediaPlaybackSuspended(true)
89
+ }
90
+ }
83
91
 
84
- MobileAds.shared.start(completionHandler: nil)
85
- call.resolve([:])
92
+ /// Only resumes once every ad that suspended has resumed, so a failed present (which can arrive
93
+ /// without a preceding adWillPresentFullScreenContent) can't release another ad's suspension.
94
+ func resumeWebMedia(for owner: AnyObject) {
95
+ Task { @MainActor [weak self] in
96
+ guard let self, self.webMediaSuspenders.remove(ObjectIdentifier(owner)) != nil,
97
+ self.webMediaSuspenders.isEmpty
98
+ else { return }
99
+ self.setWebMediaPlaybackSuspended(false)
100
+ }
101
+ }
102
+
103
+ @MainActor
104
+ func setWebMediaPlaybackSuspended(_ suspended: Bool) {
105
+ self.bridge?.webView?.setAllMediaPlaybackSuspended(suspended, completionHandler: nil)
86
106
  }
87
107
 
88
108
  /**
@@ -126,34 +146,12 @@ public class AdMobPlugin: CAPPlugin, CAPBridgedPlugin {
126
146
  }
127
147
  }
128
148
 
129
- /**
130
- * AdMob: Banner
131
- * https://developers.google.com/ad-manager/mobile-ads-sdk/ios/banner?hl=ja
132
- */
133
- @objc func showBanner(_ call: CAPPluginCall) {
134
- let adUnitID = getAdId(call, "ca-app-pub-3940256099942544/6300978111")
135
- let request = self.GADRequestWithOption(call.getBool("npa") ?? false)
136
-
137
- DispatchQueue.main.async {
138
- self.bannerExecutor.showBanner(call, request, adUnitID)
139
- }
140
- }
141
-
142
- @objc func hideBanner(_ call: CAPPluginCall) {
143
- DispatchQueue.main.async {
144
- self.bannerExecutor.hideBanner(call)
145
- }
146
- }
147
-
148
- @objc func resumeBanner(_ call: CAPPluginCall) {
149
- DispatchQueue.main.async {
150
- self.bannerExecutor.resumeBanner(call)
151
- }
152
- }
153
-
154
- @objc func removeBanner(_ call: CAPPluginCall) {
155
- DispatchQueue.main.async {
156
- self.bannerExecutor.removeBanner(call)
149
+ deinit {
150
+ let executor = nativeAdExecutor
151
+ if Thread.isMainThread {
152
+ executor.destroyAll()
153
+ } else {
154
+ DispatchQueue.main.async { executor.destroyAll() }
157
155
  }
158
156
  }
159
157
 
@@ -349,3 +347,67 @@ public class AdMobPlugin: CAPPlugin, CAPBridgedPlugin {
349
347
  return window?.rootViewController
350
348
  }
351
349
  }
350
+
351
+ extension AdMobPlugin {
352
+ // MARK: SDK initialization
353
+
354
+ @objc func initialize(_ call: CAPPluginCall) {
355
+ bannerExecutor.plugin = self
356
+ adInterstitialExecutor.plugin = self
357
+ adRewardExecutor.plugin = self
358
+ adRewardInterstitialExecutor.plugin = self
359
+ adInterstitialExecutor.plugin = self
360
+ nativeAdExecutor.plugin = self
361
+ setRequestConfiguration(call)
362
+
363
+ MobileAds.shared.start(completionHandler: nil)
364
+ call.resolve([:])
365
+ }
366
+
367
+ // MARK: Banner ads
368
+
369
+ @objc func showBanner(_ call: CAPPluginCall) {
370
+ let adUnitID = getAdId(call, "ca-app-pub-3940256099942544/6300978111")
371
+ let request = GADRequestWithOption(call.getBool("npa") ?? false)
372
+ DispatchQueue.main.async {
373
+ self.bannerExecutor.showBanner(call, request, adUnitID)
374
+ }
375
+ }
376
+
377
+ @objc func hideBanner(_ call: CAPPluginCall) {
378
+ DispatchQueue.main.async { self.bannerExecutor.hideBanner(call) }
379
+ }
380
+
381
+ @objc func resumeBanner(_ call: CAPPluginCall) {
382
+ DispatchQueue.main.async { self.bannerExecutor.resumeBanner(call) }
383
+ }
384
+
385
+ @objc func removeBanner(_ call: CAPPluginCall) {
386
+ DispatchQueue.main.async { self.bannerExecutor.removeBanner(call) }
387
+ }
388
+
389
+ // MARK: Native ads research preview
390
+
391
+ @objc func startNativeAdFeed(_ call: CAPPluginCall) {
392
+ DispatchQueue.main.async { self.nativeAdExecutor.startFeed(call) }
393
+ }
394
+
395
+ @objc func destroyNativeAdFeed(_ call: CAPPluginCall) {
396
+ DispatchQueue.main.async { self.nativeAdExecutor.destroyFeed(call) }
397
+ }
398
+
399
+ @objc func loadNativeAd(_ call: CAPPluginCall) {
400
+ let request = GADRequestWithOption(call.getBool("npa") ?? false)
401
+ DispatchQueue.main.async {
402
+ self.nativeAdExecutor.load(call, request: request)
403
+ }
404
+ }
405
+
406
+ @objc func updateNativeAdPlacements(_ call: CAPPluginCall) {
407
+ DispatchQueue.main.async { self.nativeAdExecutor.updatePlacements(call) }
408
+ }
409
+
410
+ @objc func removeNativeAd(_ call: CAPPluginCall) {
411
+ DispatchQueue.main.async { self.nativeAdExecutor.remove(call) }
412
+ }
413
+ }
@@ -73,6 +73,7 @@ class AdInterstitialExecutor: NSObject, FullScreenContentDelegate {
73
73
  }
74
74
 
75
75
  func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
76
+ self.plugin?.resumeWebMedia(for: ad)
76
77
  removeCurrentlyShowingAd()
77
78
  NSLog("InterstitialFullScreenDelegate Ad failed to present full screen content with error \(error.localizedDescription).")
78
79
  self.plugin?.notifyListeners(InterstitialAdPluginEvents.FailedToShow.rawValue, data: [
@@ -82,11 +83,13 @@ class AdInterstitialExecutor: NSObject, FullScreenContentDelegate {
82
83
  }
83
84
 
84
85
  func adWillPresentFullScreenContent(_ ad: FullScreenPresentingAd) {
86
+ self.plugin?.suspendWebMedia(for: ad)
85
87
  NSLog("InterstitialFullScreenDelegate Ad did present full screen content.")
86
88
  self.plugin?.notifyListeners(InterstitialAdPluginEvents.Showed.rawValue, data: [:])
87
89
  }
88
90
 
89
91
  func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
92
+ self.plugin?.resumeWebMedia(for: ad)
90
93
  removeCurrentlyShowingAd()
91
94
  NSLog("InterstitialFullScreenDelegate Ad did dismiss full screen content.")
92
95
  self.plugin?.notifyListeners(InterstitialAdPluginEvents.Dismissed.rawValue, data: [:])
@@ -0,0 +1,354 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import GoogleMobileAds
4
+ import UIKit
5
+
6
+ final class NativeAdExecutor: NSObject, NativeAdLoaderDelegate, NativeAdDelegate {
7
+ static let testAdUnitID = "ca-app-pub-3940256099942544/3986624511"
8
+ private let maxAdsPerFeed = 3
9
+
10
+ weak var plugin: AdMobPlugin?
11
+
12
+ private final class State {
13
+ let feedId: String
14
+ let isSmall: Bool
15
+ let sessionId: String
16
+ let slotKey: String
17
+ let nativeAd: NativeAd
18
+ let adView: PluginNativeAdView
19
+ let clippingView: UIView
20
+ var generation = -1
21
+
22
+ init(
23
+ feedId: String,
24
+ isSmall: Bool,
25
+ sessionId: String,
26
+ slotKey: String,
27
+ nativeAd: NativeAd,
28
+ adView: PluginNativeAdView,
29
+ clippingView: UIView
30
+ ) {
31
+ self.feedId = feedId
32
+ self.isSmall = isSmall
33
+ self.sessionId = sessionId
34
+ self.slotKey = slotKey
35
+ self.nativeAd = nativeAd
36
+ self.adView = adView
37
+ self.clippingView = clippingView
38
+ }
39
+ }
40
+
41
+ private struct Pending {
42
+ let loader: AdLoader
43
+ let call: CAPPluginCall
44
+ let feedId: String
45
+ let sessionId: String
46
+ let slotKey: String
47
+ let template: String
48
+ let style: [String: Any]
49
+ }
50
+
51
+ private var states: [String: State] = [:]
52
+ private var pending: [ObjectIdentifier: Pending] = [:]
53
+ private let feedSessions = NativeAdFeedSessions()
54
+ private var scrollTrackers: [String: NativeAdScrollTracker] = [:]
55
+
56
+ func startFeed(_ call: CAPPluginCall) {
57
+ guard let session = NativeAdValues.session(call) else { return }
58
+ let (feedId, sessionId) = session
59
+ if !feedSessions.isCurrent(feedId: feedId, sessionId: sessionId) { clearFeed(feedId) }
60
+ guard feedSessions.start(feedId: feedId, sessionId: sessionId) else {
61
+ call.reject("At most two native ad feeds can be active")
62
+ return
63
+ }
64
+ call.resolve()
65
+ }
66
+
67
+ func destroyFeed(_ call: CAPPluginCall) {
68
+ guard let session = NativeAdValues.session(call) else { return }
69
+ let (feedId, sessionId) = session
70
+ if feedSessions.isCurrent(feedId: feedId, sessionId: sessionId) {
71
+ clearFeed(feedId)
72
+ feedSessions.remove(feedId: feedId)
73
+ }
74
+ call.resolve()
75
+ }
76
+
77
+ func load(_ call: CAPPluginCall, request: Request) {
78
+ guard
79
+ let feedId = NativeAdValues.requiredString(call, "feedId"),
80
+ let sessionId = NativeAdValues.requiredString(call, "sessionId"),
81
+ let slotKey = NativeAdValues.requiredString(call, "slotKey")
82
+ else { return }
83
+ guard feedSessions.isCurrent(feedId: feedId, sessionId: sessionId) else {
84
+ call.reject("Native ad feed session is no longer active")
85
+ return
86
+ }
87
+ let adUnitID: String
88
+ if call.getBool("isTesting") == true {
89
+ adUnitID = Self.testAdUnitID
90
+ } else {
91
+ guard let requestedID = NativeAdValues.requiredString(call, "adId") else { return }
92
+ adUnitID = requestedID
93
+ }
94
+ let loader = AdLoader(
95
+ adUnitID: adUnitID,
96
+ rootViewController: plugin?.getRootVC(),
97
+ adTypes: [.native],
98
+ options: nil
99
+ )
100
+ let identifier = ObjectIdentifier(loader)
101
+ destroyState(NativeAdValues.key(feedId, slotKey))
102
+ cancelPending(feedId: feedId, slotKey: slotKey)
103
+ let feedAdCount = states.values.filter { $0.feedId == feedId }.count
104
+ + pending.values.filter { $0.feedId == feedId }.count
105
+ guard feedAdCount < maxAdsPerFeed else {
106
+ call.reject("At most three native ads can be active in a feed")
107
+ return
108
+ }
109
+ pending[identifier] = Pending(
110
+ loader: loader,
111
+ call: call,
112
+ feedId: feedId,
113
+ sessionId: sessionId,
114
+ slotKey: slotKey,
115
+ template: call.getString("template") ?? "medium",
116
+ style: call.getObject("style") ?? [:]
117
+ )
118
+ loader.delegate = self
119
+ loader.load(request)
120
+ }
121
+
122
+ func updatePlacements(_ call: CAPPluginCall) {
123
+ guard
124
+ let feedId = NativeAdValues.requiredString(call, "feedId"),
125
+ let sessionId = NativeAdValues.requiredString(call, "sessionId")
126
+ else { return }
127
+ guard feedSessions.isCurrent(feedId: feedId, sessionId: sessionId) else {
128
+ call.resolve()
129
+ return
130
+ }
131
+ let sequence = call.getInt("sequence") ?? -1
132
+ guard feedSessions.accepts(feedId: feedId, sequence: sequence) else {
133
+ call.resolve()
134
+ return
135
+ }
136
+ scrollTrackers.removeValue(forKey: feedId)
137
+ states.values.filter { $0.feedId == feedId }.forEach { $0.clippingView.isHidden = true }
138
+
139
+ if updateTrackedPlacements(call, feedId: feedId) { return }
140
+
141
+ for case let placement as [String: Any] in call.getArray("placements") ?? [] {
142
+ applyPlacement(placement, expectedFeedId: feedId)
143
+ }
144
+ call.resolve()
145
+ }
146
+
147
+ func remove(_ call: CAPPluginCall) {
148
+ guard
149
+ let feedId = NativeAdValues.requiredString(call, "feedId"),
150
+ let sessionId = NativeAdValues.requiredString(call, "sessionId"),
151
+ let slotKey = NativeAdValues.requiredString(call, "slotKey")
152
+ else { return }
153
+ guard feedSessions.isCurrent(feedId: feedId, sessionId: sessionId) else {
154
+ call.resolve()
155
+ return
156
+ }
157
+ cancelPending(feedId: feedId, slotKey: slotKey)
158
+ destroyState(NativeAdValues.key(feedId, slotKey))
159
+ call.resolve()
160
+ }
161
+
162
+ func destroyAll() {
163
+ scrollTrackers.removeAll()
164
+ for request in pending.values { request.call.reject("Native ad load was cancelled") }
165
+ for stateKey in Array(states.keys) { destroyState(stateKey) }
166
+ pending.removeAll()
167
+ feedSessions.removeAll()
168
+ }
169
+
170
+ func adLoader(_ adLoader: AdLoader, didReceive nativeAd: NativeAd) {
171
+ let identifier = ObjectIdentifier(adLoader)
172
+ guard let request = pending.removeValue(forKey: identifier) else { return }
173
+
174
+ if request.template == "small", nativeAd.mediaContent.hasVideoContent {
175
+ let message = "Small native ad template does not support video ads; use Medium"
176
+ var event = NativeAdValues.identity(request.feedId, request.sessionId, request.slotKey)
177
+ event["code"] = -1
178
+ event["message"] = message
179
+ plugin?.notifyListeners(NativeAdPluginEvents.failedToLoad.rawValue, data: event)
180
+ request.call.reject(message, "-1")
181
+ return
182
+ }
183
+ nativeAd.delegate = self
184
+ let adView = PluginNativeAdView(nativeAd: nativeAd, template: request.template, style: request.style)
185
+ let clippingView = UIView(frame: .zero)
186
+ clippingView.clipsToBounds = true
187
+ clippingView.isHidden = true
188
+ clippingView.addSubview(adView)
189
+
190
+ guard let webView = plugin?.bridge?.webView, let parent = webView.superview else {
191
+ nativeAd.delegate = nil
192
+ request.call.reject("AdMob WebView is unavailable")
193
+ return
194
+ }
195
+ parent.addSubview(clippingView)
196
+ let state = State(
197
+ feedId: request.feedId,
198
+ isSmall: request.template == "small",
199
+ sessionId: request.sessionId,
200
+ slotKey: request.slotKey,
201
+ nativeAd: nativeAd,
202
+ adView: adView,
203
+ clippingView: clippingView
204
+ )
205
+ states[NativeAdValues.key(request.feedId, request.slotKey)] = state
206
+
207
+ let feedId = request.feedId
208
+ let sessionId = request.sessionId
209
+ let slotKey = request.slotKey
210
+ let adUnitID = adLoader.adUnitID
211
+ nativeAd.paidEventHandler = { [weak self, weak nativeAd, weak state] adValue in
212
+ guard let self, let nativeAd, let state,
213
+ self.states[NativeAdValues.key(feedId, slotKey)] === state else { return }
214
+ self.plugin?.notifyListeners(NativeAdPluginEvents.adPaid.rawValue, data: [
215
+ "feedId": feedId,
216
+ "sessionId": sessionId,
217
+ "slotKey": slotKey,
218
+ "adUnitId": adUnitID,
219
+ "valueMicros": adValue.value.multiplying(byPowerOf10: 6).int64Value,
220
+ "currencyCode": adValue.currencyCode,
221
+ "precision": adValue.precision.rawValue,
222
+ "networkName": nativeAd.responseInfo.loadedAdNetworkResponseInfo?.adNetworkClassName ?? "",
223
+ "impressionId": nativeAd.responseInfo.responseIdentifier ?? ""
224
+ ])
225
+ }
226
+
227
+ plugin?.notifyListeners(
228
+ NativeAdPluginEvents.loaded.rawValue,
229
+ data: NativeAdValues.identity(request.feedId, request.sessionId, request.slotKey)
230
+ )
231
+ request.call.resolve()
232
+ }
233
+
234
+ private func applyPlacement(_ placement: [String: Any], expectedFeedId: String) {
235
+ guard let value = NativeAdPlacementValue(placement, expectedFeedId: expectedFeedId) else { return }
236
+ applyPlacement(value)
237
+ }
238
+
239
+ private func applyPlacement(_ placement: NativeAdPlacementValue) {
240
+ guard
241
+ let state = states[NativeAdValues.key(placement.feedId, placement.slotKey)],
242
+ placement.generation >= state.generation,
243
+ placement.rect.width >= (state.isSmall ? 120 : 144),
244
+ placement.rect.height >= (state.isSmall ? 120 : 300),
245
+ let webView = plugin?.bridge?.webView
246
+ else { return }
247
+
248
+ let webOrigin = webView.frame.origin
249
+ state.clippingView.frame = placement.clip.offsetBy(dx: webOrigin.x, dy: webOrigin.y)
250
+ state.adView.frame = CGRect(
251
+ x: placement.rect.minX - placement.clip.minX,
252
+ y: placement.rect.minY - placement.clip.minY,
253
+ width: placement.rect.width,
254
+ height: placement.rect.height
255
+ )
256
+ state.generation = placement.generation
257
+ state.clippingView.isHidden = false
258
+ state.clippingView.superview?.bringSubviewToFront(state.clippingView)
259
+ }
260
+
261
+ private func notify(_ event: NativeAdPluginEvents, nativeAd: NativeAd) {
262
+ guard let state = states.values.first(where: { $0.nativeAd === nativeAd }) else { return }
263
+ plugin?.notifyListeners(
264
+ event.rawValue,
265
+ data: NativeAdValues.identity(state.feedId, state.sessionId, state.slotKey)
266
+ )
267
+ }
268
+
269
+ private func destroyState(_ stateKey: String) {
270
+ guard let state = states.removeValue(forKey: stateKey) else { return }
271
+ state.nativeAd.delegate = nil
272
+ state.nativeAd.paidEventHandler = nil
273
+ state.adView.nativeAd = nil
274
+ state.clippingView.removeFromSuperview()
275
+ }
276
+
277
+ private func cancelPending(feedId: String, slotKey: String) {
278
+ let identifiers = pending.compactMap { identifier, request in
279
+ request.feedId == feedId && request.slotKey == slotKey ? identifier : nil
280
+ }
281
+ for identifier in identifiers {
282
+ pending.removeValue(forKey: identifier)?.call.reject("Native ad load was cancelled")
283
+ }
284
+ }
285
+
286
+ private func clearFeed(_ feedId: String) {
287
+ scrollTrackers.removeValue(forKey: feedId)
288
+ let requests = pending.filter { $0.value.feedId == feedId }
289
+ for (identifier, request) in requests {
290
+ pending.removeValue(forKey: identifier)
291
+ request.call.reject("Native ad load was cancelled")
292
+ }
293
+ for stateKey in states.filter({ $0.value.feedId == feedId }).keys {
294
+ destroyState(stateKey)
295
+ }
296
+ }
297
+
298
+ }
299
+
300
+ extension NativeAdExecutor {
301
+ private func updateTrackedPlacements(_ call: CAPPluginCall, feedId: String) -> Bool {
302
+ guard let container = call.getObject("scrollContainer") else { return false }
303
+ let placements = (call.getArray("placements") ?? []).compactMap { value -> NativeAdPlacementValue? in
304
+ guard let value = value as? [String: Any] else { return nil }
305
+ return NativeAdPlacementValue(value, expectedFeedId: feedId)
306
+ }
307
+ if placements.isEmpty {
308
+ call.resolve()
309
+ return true
310
+ }
311
+ guard let webView = plugin?.bridge?.webView,
312
+ let tracker = NativeAdScrollTracker(container: container, webView: webView, placements: placements, render: { [weak self] values in
313
+ guard let self else { return }
314
+ self.states.values.filter { $0.feedId == feedId }.forEach { $0.clippingView.isHidden = true }
315
+ values.forEach { self.applyPlacement($0) }
316
+ }) else {
317
+ call.reject("Cannot uniquely bind native ad scroll container")
318
+ return true
319
+ }
320
+ scrollTrackers[feedId] = tracker
321
+ tracker.update()
322
+ call.resolve()
323
+ return true
324
+ }
325
+
326
+ func adLoader(_ adLoader: AdLoader, didFailToReceiveAdWithError error: Error) {
327
+ let identifier = ObjectIdentifier(adLoader)
328
+ guard let request = pending.removeValue(forKey: identifier) else { return }
329
+ plugin?.notifyListeners(NativeAdPluginEvents.failedToLoad.rawValue, data: [
330
+ "feedId": request.feedId,
331
+ "sessionId": request.sessionId,
332
+ "slotKey": request.slotKey,
333
+ "code": (error as NSError).code,
334
+ "message": error.localizedDescription
335
+ ])
336
+ request.call.reject(error.localizedDescription, String((error as NSError).code), error)
337
+ }
338
+
339
+ func nativeAdDidRecordClick(_ nativeAd: NativeAd) {
340
+ notify(.clicked, nativeAd: nativeAd)
341
+ }
342
+
343
+ func nativeAdDidRecordImpression(_ nativeAd: NativeAd) {
344
+ notify(.impression, nativeAd: nativeAd)
345
+ }
346
+
347
+ func nativeAdWillPresentScreen(_ nativeAd: NativeAd) {
348
+ notify(.opened, nativeAd: nativeAd)
349
+ }
350
+
351
+ func nativeAdDidDismissScreen(_ nativeAd: NativeAd) {
352
+ notify(.closed, nativeAd: nativeAd)
353
+ }
354
+ }
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+
3
+ final class NativeAdFeedSessions {
4
+ private let maxFeeds = 2
5
+ private var sessions: [String: String] = [:]
6
+ private var placementSequences: [String: Int] = [:]
7
+
8
+ func start(feedId: String, sessionId: String) -> Bool {
9
+ guard sessions[feedId] != nil || sessions.count < maxFeeds else { return false }
10
+ sessions[feedId] = sessionId
11
+ placementSequences[feedId] = -1
12
+ return true
13
+ }
14
+
15
+ func isCurrent(feedId: String, sessionId: String) -> Bool {
16
+ sessions[feedId] == sessionId
17
+ }
18
+
19
+ func accepts(feedId: String, sequence: Int) -> Bool {
20
+ guard sequence > (placementSequences[feedId] ?? -1) else { return false }
21
+ placementSequences[feedId] = sequence
22
+ return true
23
+ }
24
+
25
+ func remove(feedId: String) {
26
+ sessions.removeValue(forKey: feedId)
27
+ placementSequences.removeValue(forKey: feedId)
28
+ }
29
+
30
+ func removeAll() {
31
+ sessions.removeAll()
32
+ placementSequences.removeAll()
33
+ }
34
+ }
@@ -0,0 +1,50 @@
1
+ import Foundation
2
+
3
+ struct NativeAdPlacementValue {
4
+ let feedId: String
5
+ let slotKey: String
6
+ let generation: Int
7
+ var rect: CGRect
8
+ var clip: CGRect
9
+
10
+ init?(_ value: [String: Any], expectedFeedId: String) {
11
+ guard
12
+ value["visible"] as? Bool == true,
13
+ let feedId = value["feedId"] as? String,
14
+ feedId == expectedFeedId,
15
+ let slotKey = value["slotKey"] as? String,
16
+ let generation = (value["generation"] as? NSNumber)?.intValue,
17
+ let rectValue = value["rect"] as? [String: Any],
18
+ let clipValue = value["clipRect"] as? [String: Any],
19
+ let rect = Self.rect(rectValue),
20
+ let clip = Self.rect(clipValue),
21
+ rect.width > 0,
22
+ rect.height > 0,
23
+ clip.width > 0,
24
+ clip.height > 0
25
+ else { return nil }
26
+
27
+ self.feedId = feedId
28
+ self.slotKey = slotKey
29
+ self.generation = generation
30
+ self.rect = rect
31
+ self.clip = clip
32
+ }
33
+
34
+ func projected(origin: CGPoint, viewport: CGRect) -> Self? {
35
+ var result = self
36
+ result.rect = rect.offsetBy(dx: origin.x, dy: origin.y)
37
+ result.clip = clip.offsetBy(dx: origin.x, dy: origin.y).intersection(viewport)
38
+ return result.clip.isNull || result.clip.isEmpty ? nil : result
39
+ }
40
+
41
+ static func rect(_ value: [String: Any]) -> CGRect? {
42
+ guard
43
+ let originX = (value["x"] as? NSNumber)?.doubleValue,
44
+ let originY = (value["y"] as? NSNumber)?.doubleValue,
45
+ let width = (value["width"] as? NSNumber)?.doubleValue,
46
+ let height = (value["height"] as? NSNumber)?.doubleValue
47
+ else { return nil }
48
+ return CGRect(x: originX, y: originY, width: width, height: height)
49
+ }
50
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ enum NativeAdPluginEvents: String {
4
+ case loaded = "nativeAdLoaded"
5
+ case failedToLoad = "nativeAdFailedToLoad"
6
+ case clicked = "nativeAdClicked"
7
+ case impression = "nativeAdImpression"
8
+ case opened = "nativeAdOpened"
9
+ case closed = "nativeAdClosed"
10
+ case adPaid = "nativeAdPaid"
11
+ }