@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
@@ -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
+ }
@@ -0,0 +1,187 @@
1
+ import Foundation
2
+ import GoogleMobileAds
3
+ import UIKit
4
+
5
+ final class PluginNativeAdView: NativeAdView {
6
+ private let isSmall: Bool
7
+ private let attributionLabel = UILabel()
8
+ private let headlineLabel = UILabel()
9
+ private let bodyLabel = UILabel()
10
+ private let advertiserLabel = UILabel()
11
+ private let iconImageView = UIImageView()
12
+ private let actionButton = UIButton(type: .system)
13
+ private let nativeMediaView = MediaView()
14
+
15
+ init(nativeAd: NativeAd, template: String, style: [String: Any]) {
16
+ self.isSmall = template == "small"
17
+ super.init(frame: .zero)
18
+ clipsToBounds = true
19
+ configure(nativeAd: nativeAd, style: style)
20
+ }
21
+
22
+ required init?(coder: NSCoder) {
23
+ fatalError("init(coder:) has not been implemented")
24
+ }
25
+
26
+ override func layoutSubviews() {
27
+ super.layoutSubviews()
28
+ let padding: CGFloat = 12
29
+ let available = bounds.insetBy(dx: padding, dy: padding)
30
+
31
+ attributionLabel.frame = CGRect(x: padding, y: padding, width: 28, height: 20)
32
+
33
+ if isSmall {
34
+ layoutDetails(
35
+ in: CGRect(
36
+ x: available.minX,
37
+ y: attributionLabel.frame.maxY + 4,
38
+ width: available.width,
39
+ height: max(0, available.maxY - attributionLabel.frame.maxY - 4)
40
+ )
41
+ )
42
+ } else {
43
+ let mediaHeight = max(120, available.height * 0.58)
44
+ nativeMediaView.frame = CGRect(
45
+ x: available.minX,
46
+ y: available.minY,
47
+ width: available.width,
48
+ height: min(mediaHeight, available.height)
49
+ )
50
+ layoutDetails(
51
+ in: CGRect(
52
+ x: available.minX,
53
+ y: nativeMediaView.frame.maxY + 8,
54
+ width: available.width,
55
+ height: max(0, available.maxY - nativeMediaView.frame.maxY - 8)
56
+ )
57
+ )
58
+ }
59
+ bringSubviewToFront(attributionLabel)
60
+ }
61
+
62
+ private func layoutDetails(in rect: CGRect) {
63
+ let headlineHeight = min(42, max(22, rect.height * 0.27))
64
+ headlineLabel.frame = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: headlineHeight)
65
+ let bodyHeight = bodyLabel.isHidden ? 0 : min(38, max(18, rect.height * 0.22))
66
+ bodyLabel.frame = CGRect(x: rect.minX, y: headlineLabel.frame.maxY, width: rect.width, height: bodyHeight)
67
+
68
+ let footerY = bodyLabel.frame.maxY + 4
69
+ let footerHeight = max(0, rect.maxY - footerY)
70
+ let iconSize = min(32, footerHeight)
71
+ iconImageView.frame = CGRect(x: rect.minX, y: footerY, width: iconSize, height: iconSize)
72
+ let actionWidth = actionButton.isHidden ? 0 : min(128, rect.width * 0.4)
73
+ actionButton.frame = CGRect(x: rect.maxX - actionWidth, y: footerY, width: actionWidth, height: min(40, footerHeight))
74
+ advertiserLabel.frame = CGRect(
75
+ x: iconImageView.isHidden ? rect.minX : iconImageView.frame.maxX + 6,
76
+ y: footerY,
77
+ width: max(0, actionButton.frame.minX - (iconImageView.isHidden ? rect.minX : iconImageView.frame.maxX + 6) - 6),
78
+ height: min(32, footerHeight)
79
+ )
80
+ }
81
+
82
+ private func configure(nativeAd: NativeAd, style: [String: Any]) {
83
+ backgroundColor = color(style["backgroundColor"] as? String) ?? .white
84
+ layer.cornerRadius = number(style["cornerRadius"], fallback: 0)
85
+ layer.borderWidth = number(style["borderWidth"], fallback: 0)
86
+ layer.borderColor = (color(style["borderColor"] as? String) ?? .clear).cgColor
87
+
88
+ configureAttribution()
89
+
90
+ headlineLabel.text = nativeAd.headline
91
+ headlineLabel.numberOfLines = 2
92
+ headlineLabel.font = .boldSystemFont(
93
+ ofSize: fontSize(style["headlineFontSize"], fallback: 16, range: 12 ... 24)
94
+ )
95
+ headlineLabel.textColor = color(style["headlineColor"] as? String) ?? .black
96
+ addSubview(headlineLabel)
97
+ headlineView = headlineLabel
98
+
99
+ bodyLabel.text = nativeAd.body
100
+ bodyLabel.numberOfLines = isSmall ? 1 : 2
101
+ bodyLabel.font = .systemFont(ofSize: fontSize(style["bodyFontSize"], fallback: 13, range: 10 ... 18))
102
+ bodyLabel.textColor = color(style["bodyColor"] as? String) ?? .darkGray
103
+ bodyLabel.isHidden = isSmall || nativeAd.body == nil
104
+ addSubview(bodyLabel)
105
+ bodyView = bodyLabel
106
+
107
+ advertiserLabel.text = nativeAd.advertiser
108
+ advertiserLabel.font = .systemFont(ofSize: 11)
109
+ advertiserLabel.numberOfLines = 1
110
+ advertiserLabel.isHidden = nativeAd.advertiser == nil
111
+ addSubview(advertiserLabel)
112
+ advertiserView = advertiserLabel
113
+
114
+ iconImageView.image = nativeAd.icon?.image
115
+ iconImageView.contentMode = .scaleAspectFill
116
+ iconImageView.clipsToBounds = true
117
+ iconImageView.isHidden = nativeAd.icon == nil
118
+ addSubview(iconImageView)
119
+ iconView = iconImageView
120
+
121
+ actionButton.setTitle(nativeAd.callToAction, for: .normal)
122
+ actionButton.titleLabel?.font = .systemFont(
123
+ ofSize: fontSize(style["callToActionFontSize"], fallback: 13, range: 12 ... 18)
124
+ )
125
+ actionButton.setTitleColor(color(style["callToActionTextColor"] as? String) ?? .white, for: .normal)
126
+ actionButton.backgroundColor = color(style["callToActionBackgroundColor"] as? String) ?? UIColor(
127
+ red: 33 / 255,
128
+ green: 150 / 255,
129
+ blue: 243 / 255,
130
+ alpha: 1
131
+ )
132
+ actionButton.isUserInteractionEnabled = false
133
+ actionButton.isHidden = nativeAd.callToAction == nil
134
+ addSubview(actionButton)
135
+ callToActionView = actionButton
136
+
137
+ configureMedia(nativeAd)
138
+
139
+ self.nativeAd = nativeAd
140
+ }
141
+
142
+ private func configureAttribution() {
143
+ attributionLabel.text = "Ad"
144
+ attributionLabel.textAlignment = .center
145
+ attributionLabel.font = .boldSystemFont(ofSize: 10)
146
+ attributionLabel.textColor = .white
147
+ attributionLabel.backgroundColor = .darkGray
148
+ addSubview(attributionLabel)
149
+ }
150
+
151
+ private func configureMedia(_ nativeAd: NativeAd) {
152
+ guard !isSmall else { return }
153
+ nativeMediaView.mediaContent = nativeAd.mediaContent
154
+ nativeMediaView.contentMode = .scaleAspectFit
155
+ nativeMediaView.clipsToBounds = true
156
+ addSubview(nativeMediaView)
157
+ mediaView = nativeMediaView
158
+ }
159
+
160
+ private func color(_ value: String?) -> UIColor? {
161
+ guard var value = value?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil }
162
+ if value.hasPrefix("#") { value.removeFirst() }
163
+ guard value.count == 6 || value.count == 8, let number = UInt64(value, radix: 16) else { return nil }
164
+ if value.count == 8 {
165
+ return UIColor(
166
+ red: CGFloat((number >> 24) & 0xff) / 255,
167
+ green: CGFloat((number >> 16) & 0xff) / 255,
168
+ blue: CGFloat((number >> 8) & 0xff) / 255,
169
+ alpha: CGFloat(number & 0xff) / 255
170
+ )
171
+ }
172
+ return UIColor(
173
+ red: CGFloat((number >> 16) & 0xff) / 255,
174
+ green: CGFloat((number >> 8) & 0xff) / 255,
175
+ blue: CGFloat(number & 0xff) / 255,
176
+ alpha: 1
177
+ )
178
+ }
179
+
180
+ private func number(_ value: Any?, fallback: CGFloat) -> CGFloat {
181
+ max(0, CGFloat((value as? NSNumber)?.doubleValue ?? Double(fallback)))
182
+ }
183
+
184
+ private func fontSize(_ value: Any?, fallback: CGFloat, range: ClosedRange<CGFloat>) -> CGFloat {
185
+ min(range.upperBound, max(range.lowerBound, number(value, fallback: fallback)))
186
+ }
187
+ }
@@ -97,6 +97,7 @@ class AdRewardExecutor: NSObject, FullScreenContentDelegate {
97
97
  }
98
98
 
99
99
  func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
100
+ self.plugin?.resumeWebMedia(for: ad)
100
101
  removeCurrentlyShowingAd()
101
102
  NSLog("RewardFullScreenDelegate Ad failed to present full screen content with error \(error.localizedDescription).")
102
103
  self.plugin?.notifyListeners(RewardAdPluginEvents.FailedToShow.rawValue, data: [
@@ -106,6 +107,7 @@ class AdRewardExecutor: NSObject, FullScreenContentDelegate {
106
107
  }
107
108
 
108
109
  func adWillPresentFullScreenContent(_ ad: FullScreenPresentingAd) {
110
+ self.plugin?.suspendWebMedia(for: ad)
109
111
  NSLog("RewardFullScreenDelegate Ad did present full screen content.")
110
112
  self.plugin?.notifyListeners(RewardAdPluginEvents.Showed.rawValue, data: [:])
111
113
  }
@@ -115,6 +117,7 @@ class AdRewardExecutor: NSObject, FullScreenContentDelegate {
115
117
  }
116
118
 
117
119
  func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
120
+ self.plugin?.resumeWebMedia(for: ad)
118
121
  removeCurrentlyShowingAd()
119
122
  NSLog("RewardFullScreenDelegate Ad did dismiss full screen content.")
120
123
  self.plugin?.notifyListeners(RewardAdPluginEvents.Dismissed.rawValue, data: [:])
@@ -97,6 +97,7 @@ class AdRewardInterstitialExecutor: NSObject, FullScreenContentDelegate {
97
97
  }
98
98
 
99
99
  func ad(_ ad: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
100
+ self.plugin?.resumeWebMedia(for: ad)
100
101
  removeCurrentlyShowingAd()
101
102
  NSLog("RewardFullScreenDelegate Ad failed to present full screen content with error \(error.localizedDescription).")
102
103
  self.plugin?.notifyListeners(RewardInterstitialAdPluginEvents.FailedToShow.rawValue, data: [
@@ -106,11 +107,13 @@ class AdRewardInterstitialExecutor: NSObject, FullScreenContentDelegate {
106
107
  }
107
108
 
108
109
  func adWillPresentFullScreenContent(_ ad: FullScreenPresentingAd) {
110
+ self.plugin?.suspendWebMedia(for: ad)
109
111
  NSLog("RewardFullScreenDelegate Ad did present full screen content.")
110
112
  self.plugin?.notifyListeners(RewardInterstitialAdPluginEvents.Showed.rawValue, data: [:])
111
113
  }
112
114
 
113
115
  func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
116
+ self.plugin?.resumeWebMedia(for: ad)
114
117
  removeCurrentlyShowingAd()
115
118
  NSLog("RewardFullScreenDelegate Ad did dismiss full screen content.")
116
119
  self.plugin?.notifyListeners(RewardInterstitialAdPluginEvents.Dismissed.rawValue, data: [:])
@@ -1,15 +1,87 @@
1
1
  import XCTest
2
2
  @testable import AdMobPlugin
3
3
 
4
- class AdMobTests: XCTestCase {
5
- func testEcho() {
6
- // This is an example of a functional test case for a plugin.
7
- // Use XCTAssert and related functions to verify your tests produce the correct results.
8
-
9
- // let implementation = AdMob()
10
- // let value = "Hello, World!"
11
- // let result = implementation.echo(value)
12
- //
13
- // XCTAssertEqual(value, result)
4
+ final class AdMobPluginTests: XCTestCase {
5
+ @MainActor
6
+ func testNormalSuspensionAndResume() async {
7
+ await checkSuspensions([(0, true, true), (0, false, false)])
8
+ }
9
+
10
+ @MainActor
11
+ func testFailedAdCannotReleaseAnotherAdsSuspension() async {
12
+ await checkSuspensions([(0, true, true), (1, false, nil), (0, false, false)])
13
+ }
14
+
15
+ @MainActor
16
+ func testOnlyLastAdResumesMedia() async {
17
+ await checkSuspensions([(0, true, true), (1, true, nil), (0, false, nil), (1, false, false)])
18
+ }
19
+
20
+ @MainActor
21
+ private func checkSuspensions(_ steps: [(Int, Bool, Bool?)]) async {
22
+ let plugin = MediaTestPlugin()
23
+ let owners = [NSObject(), NSObject()]
24
+
25
+ for (owner, suspend, expected) in steps {
26
+ let called = expectation(description: "WebKit suspension: \(String(describing: expected))")
27
+ called.isInverted = expected == nil
28
+ plugin.onSuspension = { actual in
29
+ XCTAssertEqual(actual, expected)
30
+ called.fulfill()
31
+ }
32
+ if suspend {
33
+ plugin.suspendWebMedia(for: owners[owner])
34
+ } else {
35
+ plugin.resumeWebMedia(for: owners[owner])
36
+ }
37
+ await fulfillment(of: [called], timeout: expected == nil ? 0.1 : 2)
38
+ }
39
+ plugin.onSuspension = nil
40
+ }
41
+
42
+ func testNativeFeedSessionsEnforceCapacityAndAllowReplacement() {
43
+ let sessions = NativeAdFeedSessions()
44
+
45
+ XCTAssertTrue(sessions.start(feedId: "first", sessionId: "session-1"))
46
+ XCTAssertTrue(sessions.start(feedId: "second", sessionId: "session-2"))
47
+ XCTAssertFalse(sessions.start(feedId: "third", sessionId: "session-3"))
48
+ XCTAssertTrue(sessions.start(feedId: "first", sessionId: "replacement"))
49
+ XCTAssertTrue(sessions.isCurrent(feedId: "first", sessionId: "replacement"))
50
+ }
51
+
52
+ func testNativeFeedSessionsRejectStalePlacementSequences() {
53
+ let sessions = NativeAdFeedSessions()
54
+ XCTAssertTrue(sessions.start(feedId: "feed", sessionId: "session"))
55
+
56
+ XCTAssertTrue(sessions.accepts(feedId: "feed", sequence: 1))
57
+ XCTAssertFalse(sessions.accepts(feedId: "feed", sequence: 1))
58
+ XCTAssertFalse(sessions.accepts(feedId: "feed", sequence: 0))
59
+ XCTAssertTrue(sessions.accepts(feedId: "feed", sequence: 2))
60
+ }
61
+
62
+ func testNativePlacementRejectsWrongFeedAndMalformedRect() {
63
+ let valid: [String: Any] = [
64
+ "visible": true,
65
+ "feedId": "feed",
66
+ "slotKey": "slot",
67
+ "generation": 1,
68
+ "rect": ["x": 0, "y": 0, "width": 144, "height": 300],
69
+ "clipRect": ["x": 0, "y": 0, "width": 144, "height": 300]
70
+ ]
71
+
72
+ XCTAssertNotNil(NativeAdPlacementValue(valid, expectedFeedId: "feed"))
73
+ XCTAssertNil(NativeAdPlacementValue(valid, expectedFeedId: "another-feed"))
74
+ var malformed = valid
75
+ malformed["rect"] = ["x": 0, "y": 0, "width": 0, "height": 300]
76
+ XCTAssertNil(NativeAdPlacementValue(malformed, expectedFeedId: "feed"))
77
+ }
78
+ }
79
+
80
+ private final class MediaTestPlugin: AdMobPlugin {
81
+ var onSuspension: ((Bool) -> Void)?
82
+
83
+ @MainActor
84
+ override func setWebMediaPlaybackSuspended(_ suspended: Bool) {
85
+ onSuspension?(suspended)
14
86
  }
15
87
  }
@@ -0,0 +1,47 @@
1
+ import XCTest
2
+ import WebKit
3
+ @testable import AdMobPlugin
4
+
5
+ final class NativeAdScrollTrackerTests: XCTestCase {
6
+ @MainActor
7
+ func testScrollClipsPlacementsAndReleasingTrackerStopsUpdates() throws {
8
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640))
9
+ let web = WKWebView(frame: window.bounds)
10
+ window.addSubview(web)
11
+ let scroll = UIScrollView(frame: CGRect(x: 0, y: 80, width: 320, height: 500))
12
+ scroll.contentSize = CGSize(width: 320, height: 1200)
13
+ web.addSubview(scroll)
14
+ let placement = try XCTUnwrap(NativeAdPlacementValue([
15
+ "feedId": "feed", "slotKey": "ad", "generation": 1, "visible": true,
16
+ "rect": ["x": 10, "y": 400, "width": 300, "height": 320],
17
+ "clipRect": ["x": 10, "y": 400, "width": 300, "height": 320]
18
+ ], expectedFeedId: "feed"))
19
+ let container: [String: Any] = [
20
+ "rect": ["x": 0, "y": 80, "width": 320, "height": 500],
21
+ "clipRect": ["x": 0, "y": 100, "width": 320, "height": 480],
22
+ "contentWidth": 320.0, "contentHeight": 1200.0
23
+ ]
24
+ var rendered: [NativeAdPlacementValue] = []
25
+ var updates = 0
26
+ var tracker: NativeAdScrollTracker? = try XCTUnwrap(NativeAdScrollTracker(
27
+ container: container, webView: web, placements: [placement],
28
+ render: { rendered = $0; updates += 1 }
29
+ ))
30
+ tracker?.update()
31
+ XCTAssertEqual(rendered.first?.clip, CGRect(x: 10, y: 480, width: 300, height: 100))
32
+ scroll.contentOffset.y = 450
33
+ XCTAssertEqual(rendered.first?.rect, CGRect(x: 10, y: 30, width: 300, height: 320))
34
+ XCTAssertEqual(rendered.first?.clip, CGRect(x: 10, y: 100, width: 300, height: 250))
35
+ scroll.contentOffset.y = 800
36
+ XCTAssertTrue(rendered.isEmpty)
37
+ tracker = nil
38
+ let previousUpdates = updates
39
+ scroll.contentOffset.y = 0
40
+ XCTAssertEqual(updates, previousUpdates)
41
+
42
+ let duplicate = UIScrollView(frame: scroll.frame)
43
+ duplicate.contentSize = scroll.contentSize
44
+ web.addSubview(duplicate)
45
+ XCTAssertNil(NativeAdScrollTracker(container: container, webView: web, placements: [placement], render: { _ in }))
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capacitor-community/admob",
3
3
  "private": false,
4
- "version": "8.2.0",
4
+ "version": "8.2.1",
5
5
  "description": "A native plugin for AdMob",
6
6
  "main": "dist/plugin.cjs.js",
7
7
  "module": "dist/esm/index.js",
@@ -33,6 +33,7 @@
33
33
  "scripts": {
34
34
  "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
35
35
  "verify:ios": "xcodebuild -scheme CapacitorCommunityAdmob -destination generic/platform=iOS",
36
+ "test:ios": "xcodebuild test -scheme CapacitorCommunityAdmob",
36
37
  "verify:android": "cd android && ./gradlew clean build test && cd ..",
37
38
  "verify:web": "npm run build",
38
39
  "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",