@capacitor-community/admob 8.2.0-beta.pr484.shaa6be63853bbd → 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 (62) hide show
  1. package/README.md +1 -0
  2. package/android/src/main/java/com/getcapacitor/community/admob/AdMob.java +73 -0
  3. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdExecutor.kt +378 -0
  4. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdFeedSessions.kt +35 -0
  5. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPlacementValue.kt +44 -0
  6. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPluginEvents.kt +11 -0
  7. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/PluginNativeAdView.kt +187 -0
  8. package/dist/esm/index.d.ts +1 -0
  9. package/dist/esm/index.js +5 -1
  10. package/dist/esm/index.js.map +1 -1
  11. package/dist/esm/native-ads/index.d.ts +6 -0
  12. package/dist/esm/native-ads/index.js +4 -0
  13. package/dist/esm/native-ads/index.js.map +1 -0
  14. package/dist/esm/native-ads/native-ad-definitions.interface.d.ts +14 -0
  15. package/dist/esm/native-ads/native-ad-definitions.interface.js +2 -0
  16. package/dist/esm/native-ads/native-ad-definitions.interface.js.map +1 -0
  17. package/dist/esm/native-ads/native-ad-element.d.ts +9 -0
  18. package/dist/esm/native-ads/native-ad-element.js +53 -0
  19. package/dist/esm/native-ads/native-ad-element.js.map +1 -0
  20. package/dist/esm/native-ads/native-ad-event.interface.d.ts +5 -0
  21. package/dist/esm/native-ads/native-ad-event.interface.js +2 -0
  22. package/dist/esm/native-ads/native-ad-event.interface.js.map +1 -0
  23. package/dist/esm/native-ads/native-ad-feed.d.ts +72 -0
  24. package/dist/esm/native-ads/native-ad-feed.js +568 -0
  25. package/dist/esm/native-ads/native-ad-feed.js.map +1 -0
  26. package/dist/esm/native-ads/native-ad-geometry.d.ts +8 -0
  27. package/dist/esm/native-ads/native-ad-geometry.js +91 -0
  28. package/dist/esm/native-ads/native-ad-geometry.js.map +1 -0
  29. package/dist/esm/native-ads/native-ad-options.interface.d.ts +36 -0
  30. package/dist/esm/native-ads/native-ad-options.interface.js +2 -0
  31. package/dist/esm/native-ads/native-ad-options.interface.js.map +1 -0
  32. package/dist/esm/native-ads/native-ad-placement.interface.d.ts +26 -0
  33. package/dist/esm/native-ads/native-ad-placement.interface.js +2 -0
  34. package/dist/esm/native-ads/native-ad-placement.interface.js.map +1 -0
  35. package/dist/esm/native-ads/native-ad-plugin-events.enum.d.ts +9 -0
  36. package/dist/esm/native-ads/native-ad-plugin-events.enum.js +11 -0
  37. package/dist/esm/native-ads/native-ad-plugin-events.enum.js.map +1 -0
  38. package/dist/esm/native-ads/native-ad-style.interface.d.ts +30 -0
  39. package/dist/esm/native-ads/native-ad-style.interface.js +2 -0
  40. package/dist/esm/native-ads/native-ad-style.interface.js.map +1 -0
  41. package/dist/esm/native-ads/native-ad-template.enum.d.ts +11 -0
  42. package/dist/esm/native-ads/native-ad-template.enum.js +13 -0
  43. package/dist/esm/native-ads/native-ad-template.enum.js.map +1 -0
  44. package/dist/esm/web.d.ts +9 -1
  45. package/dist/esm/web.js +15 -0
  46. package/dist/esm/web.js.map +1 -1
  47. package/dist/plugin.cjs.js +751 -1
  48. package/dist/plugin.cjs.js.map +1 -1
  49. package/dist/plugin.js +751 -1
  50. package/dist/plugin.js.map +1 -1
  51. package/docs/native-ads.md +161 -0
  52. package/ios/Sources/AdMobPlugin/AdMobPlugin.swift +76 -44
  53. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdExecutor.swift +354 -0
  54. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdFeedSessions.swift +34 -0
  55. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPlacementValue.swift +50 -0
  56. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPluginEvents.swift +11 -0
  57. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdScrollTracker.swift +66 -0
  58. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdValues.swift +27 -0
  59. package/ios/Sources/AdMobPlugin/NativeAds/PluginNativeAdView.swift +187 -0
  60. package/ios/Tests/AdMobPluginTests/AdMobPluginTests.swift +37 -0
  61. package/ios/Tests/AdMobPluginTests/NativeAdScrollTrackerTests.swift +47 -0
  62. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// Research-preview binding to a single HTML overflow scroll container.
5
+ final class NativeAdScrollTracker {
6
+ private weak var webView: WKWebView?
7
+ private weak var scrollView: UIScrollView?
8
+ private let placements: [NativeAdPlacementValue]
9
+ private let clip: CGRect
10
+ private let render: ([NativeAdPlacementValue]) -> Void
11
+ private var observation: NSKeyValueObservation?
12
+
13
+ init?(
14
+ container: [String: Any],
15
+ webView: WKWebView,
16
+ placements: [NativeAdPlacementValue],
17
+ render: @escaping ([NativeAdPlacementValue]) -> Void
18
+ ) {
19
+ guard
20
+ let rectValue = container["rect"] as? [String: Any],
21
+ let clipValue = container["clipRect"] as? [String: Any],
22
+ let rect = NativeAdPlacementValue.rect(rectValue),
23
+ let clip = NativeAdPlacementValue.rect(clipValue),
24
+ let width = container["contentWidth"] as? Double,
25
+ let height = container["contentHeight"] as? Double,
26
+ [rect.minX, rect.minY, rect.width, rect.height,
27
+ clip.minX, clip.minY, clip.width, clip.height, width, height].allSatisfy({ $0.isFinite }),
28
+ rect.width > 0, rect.height > 0
29
+ else { return nil }
30
+
31
+ let candidates = Self.scrollViews(in: webView).filter { scroll in
32
+ let frame = scroll.convert(scroll.bounds, to: webView)
33
+ return scroll !== webView.scrollView && !scroll.isHidden && scroll.window != nil &&
34
+ abs(scroll.zoomScale - 1) < 0.001 &&
35
+ abs(frame.minX - rect.minX) < 2 && abs(frame.minY - rect.minY) < 2 &&
36
+ abs(frame.width - rect.width) < 2 && abs(frame.height - rect.height) < 2 &&
37
+ abs(scroll.contentSize.width - width) < 3 && abs(scroll.contentSize.height - height) < 3
38
+ }
39
+ guard candidates.count == 1, let scroll = candidates.first else { return nil }
40
+ self.webView = webView
41
+ self.scrollView = scroll
42
+ self.placements = placements
43
+ self.clip = clip
44
+ self.render = render
45
+ observation = scroll.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in
46
+ self?.update()
47
+ }
48
+ }
49
+
50
+ func update() {
51
+ guard let webView, let scrollView, scrollView.isDescendant(of: webView) else {
52
+ render([])
53
+ return
54
+ }
55
+ let frame = scrollView.convert(scrollView.bounds, to: webView)
56
+ let viewport = clip.intersection(webView.bounds).intersection(frame)
57
+ let origin = CGPoint(x: frame.minX - scrollView.contentOffset.x, y: frame.minY - scrollView.contentOffset.y)
58
+ render(placements.compactMap { $0.projected(origin: origin, viewport: viewport) })
59
+ }
60
+
61
+ private static func scrollViews(in view: UIView) -> [UIScrollView] {
62
+ view.subviews.flatMap { child in
63
+ ((child as? UIScrollView).map { [$0] } ?? []) + scrollViews(in: child)
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,27 @@
1
+ import Capacitor
2
+ import Foundation
3
+
4
+ enum NativeAdValues {
5
+ static func session(_ call: CAPPluginCall) -> (feedId: String, sessionId: String)? {
6
+ guard let feedId = requiredString(call, "feedId"), let sessionId = requiredString(call, "sessionId") else {
7
+ return nil
8
+ }
9
+ return (feedId, sessionId)
10
+ }
11
+
12
+ static func requiredString(_ call: CAPPluginCall, _ name: String) -> String? {
13
+ guard let value = call.getString(name)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
14
+ call.reject("\(name) must not be empty")
15
+ return nil
16
+ }
17
+ return value
18
+ }
19
+
20
+ static func key(_ feedId: String, _ slotKey: String) -> String {
21
+ "\(feedId)\u{0}\(slotKey)"
22
+ }
23
+
24
+ static func identity(_ feedId: String, _ sessionId: String, _ slotKey: String) -> [String: Any] {
25
+ ["feedId": feedId, "sessionId": sessionId, "slotKey": slotKey]
26
+ }
27
+ }