@capacitor-community/admob 8.2.0 → 8.2.1-beta.pr490.shaf4a3cab095f4

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 (81) hide show
  1. package/CapacitorCommunityAdmob.podspec +1 -1
  2. package/Package.swift +1 -1
  3. package/README.md +8 -7
  4. package/android/build.gradle +5 -1
  5. package/android/src/main/java/com/getcapacitor/community/admob/AdMob.java +88 -0
  6. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdExecutor.kt +626 -0
  7. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdFeedSessions.kt +35 -0
  8. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPlacementValue.kt +44 -0
  9. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPluginEvents.kt +11 -0
  10. package/android/src/main/java/com/getcapacitor/community/admob/nativeads/PluginNativeAdView.kt +187 -0
  11. package/dist/esm/index.d.ts +1 -0
  12. package/dist/esm/index.js +5 -1
  13. package/dist/esm/index.js.map +1 -1
  14. package/dist/esm/native-ads/index.d.ts +6 -0
  15. package/dist/esm/native-ads/index.js +4 -0
  16. package/dist/esm/native-ads/index.js.map +1 -0
  17. package/dist/esm/native-ads/native-ad-definitions.interface.d.ts +20 -0
  18. package/dist/esm/native-ads/native-ad-definitions.interface.js +2 -0
  19. package/dist/esm/native-ads/native-ad-definitions.interface.js.map +1 -0
  20. package/dist/esm/native-ads/native-ad-element.d.ts +9 -0
  21. package/dist/esm/native-ads/native-ad-element.js +53 -0
  22. package/dist/esm/native-ads/native-ad-element.js.map +1 -0
  23. package/dist/esm/native-ads/native-ad-event.interface.d.ts +5 -0
  24. package/dist/esm/native-ads/native-ad-event.interface.js +2 -0
  25. package/dist/esm/native-ads/native-ad-event.interface.js.map +1 -0
  26. package/dist/esm/native-ads/native-ad-feed.d.ts +72 -0
  27. package/dist/esm/native-ads/native-ad-feed.js +643 -0
  28. package/dist/esm/native-ads/native-ad-feed.js.map +1 -0
  29. package/dist/esm/native-ads/native-ad-geometry.d.ts +8 -0
  30. package/dist/esm/native-ads/native-ad-geometry.js +92 -0
  31. package/dist/esm/native-ads/native-ad-geometry.js.map +1 -0
  32. package/dist/esm/native-ads/native-ad-options.interface.d.ts +40 -0
  33. package/dist/esm/native-ads/native-ad-options.interface.js +2 -0
  34. package/dist/esm/native-ads/native-ad-options.interface.js.map +1 -0
  35. package/dist/esm/native-ads/native-ad-placement.interface.d.ts +28 -0
  36. package/dist/esm/native-ads/native-ad-placement.interface.js +2 -0
  37. package/dist/esm/native-ads/native-ad-placement.interface.js.map +1 -0
  38. package/dist/esm/native-ads/native-ad-plugin-events.enum.d.ts +9 -0
  39. package/dist/esm/native-ads/native-ad-plugin-events.enum.js +11 -0
  40. package/dist/esm/native-ads/native-ad-plugin-events.enum.js.map +1 -0
  41. package/dist/esm/native-ads/native-ad-style.interface.d.ts +30 -0
  42. package/dist/esm/native-ads/native-ad-style.interface.js +2 -0
  43. package/dist/esm/native-ads/native-ad-style.interface.js.map +1 -0
  44. package/dist/esm/native-ads/native-ad-template.enum.d.ts +11 -0
  45. package/dist/esm/native-ads/native-ad-template.enum.js +13 -0
  46. package/dist/esm/native-ads/native-ad-template.enum.js.map +1 -0
  47. package/dist/esm/web.d.ts +9 -1
  48. package/dist/esm/web.js +15 -0
  49. package/dist/esm/web.js.map +1 -1
  50. package/dist/plugin.cjs.js +883 -1
  51. package/dist/plugin.cjs.js.map +1 -1
  52. package/dist/plugin.js +883 -1
  53. package/dist/plugin.js.map +1 -1
  54. package/docs/configuration.md +2 -2
  55. package/docs/consent.md +6 -6
  56. package/docs/events.md +3 -2
  57. package/docs/migration.md +22 -2
  58. package/docs/native-ads.md +187 -0
  59. package/docs/rewarded.md +13 -0
  60. package/ios/Sources/AdMobPlugin/AdMobPlugin.swift +103 -41
  61. package/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPluginEvents.swift +3 -0
  62. package/ios/Sources/AdMobPlugin/Banner/BannerAdPluginEvents.swift +3 -0
  63. package/ios/Sources/AdMobPlugin/Banner/BannerAdSizeEnum.swift +3 -0
  64. package/ios/Sources/AdMobPlugin/Banner/BannerExecutor.swift +4 -4
  65. package/ios/Sources/AdMobPlugin/Helper/AuthorizationStatusEnum.swift +3 -0
  66. package/ios/Sources/AdMobPlugin/Interstitial/AdInterstitialExecutor.swift +3 -0
  67. package/ios/Sources/AdMobPlugin/Interstitial/InterstitialAdPluginEvents.swift +3 -0
  68. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdExecutor.swift +354 -0
  69. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdFeedSessions.swift +34 -0
  70. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPlacementValue.swift +50 -0
  71. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPluginEvents.swift +11 -0
  72. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdScrollTracker.swift +66 -0
  73. package/ios/Sources/AdMobPlugin/NativeAds/NativeAdValues.swift +27 -0
  74. package/ios/Sources/AdMobPlugin/NativeAds/PluginNativeAdView.swift +187 -0
  75. package/ios/Sources/AdMobPlugin/Rewarded/AdRewardExecutor.swift +3 -0
  76. package/ios/Sources/AdMobPlugin/Rewarded/RewardAdPluginEvents.swift +3 -0
  77. package/ios/Sources/AdMobPlugin/RewardedInterstitial/AdRewardInterstitialExecutor.swift +3 -0
  78. package/ios/Sources/AdMobPlugin/RewardedInterstitial/RewardInterstitialAdPluginEvents.swift +3 -0
  79. package/ios/Tests/AdMobPluginTests/AdMobPluginTests.swift +82 -10
  80. package/ios/Tests/AdMobPluginTests/NativeAdScrollTrackerTests.swift +47 -0
  81. package/package.json +6 -4
@@ -0,0 +1,626 @@
1
+ package com.getcapacitor.community.admob.nativeads
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import io.github.webview_native_ui.engine.NativeUIProjection
6
+ import android.graphics.Bitmap
7
+ import android.graphics.Canvas
8
+ import android.graphics.Color
9
+ import android.graphics.drawable.GradientDrawable
10
+ import android.os.Debug
11
+ import android.os.Handler
12
+ import android.os.Looper
13
+ import android.os.SystemClock
14
+ import android.util.Base64
15
+ import android.view.View
16
+ import android.view.ViewGroup
17
+ import android.webkit.WebView
18
+ import android.widget.FrameLayout
19
+ import androidx.core.util.Supplier
20
+ import com.getcapacitor.JSArray
21
+ import com.getcapacitor.JSObject
22
+ import com.getcapacitor.PluginCall
23
+ import com.getcapacitor.community.admob.helpers.RequestHelper
24
+ import com.getcapacitor.community.admob.models.AdMobRevenueData
25
+ import com.getcapacitor.community.admob.models.AdOptions
26
+ import com.getcapacitor.community.admob.models.Executor
27
+ import com.google.android.gms.ads.AdListener
28
+ import com.google.android.gms.ads.AdLoader
29
+ import com.google.android.gms.ads.LoadAdError
30
+ import com.google.android.gms.ads.nativead.NativeAd
31
+ import com.google.android.gms.ads.nativead.NativeAdOptions
32
+ import com.google.android.gms.ads.nativead.NativeAdView
33
+ import com.google.android.gms.common.util.BiConsumer
34
+ import org.json.JSONException
35
+ import org.json.JSONObject
36
+ import java.util.concurrent.atomic.AtomicBoolean
37
+ import java.io.ByteArrayOutputStream
38
+ import kotlin.math.roundToInt
39
+
40
+ class NativeAdExecutor(
41
+ contextSupplier: Supplier<Context>,
42
+ activitySupplier: Supplier<Activity>,
43
+ private val webViewSupplier: Supplier<View>,
44
+ notifyListenersFunction: BiConsumer<String, JSObject>,
45
+ pluginLogTag: String,
46
+ ) : Executor(contextSupplier, activitySupplier, notifyListenersFunction, pluginLogTag, "NativeAdExecutor") {
47
+ private data class NativeAdState(
48
+ val feedId: String,
49
+ val isSmall: Boolean,
50
+ val loadToken: Any,
51
+ val nativeAd: NativeAd,
52
+ val adView: NativeAdView,
53
+ val clippingContainer: FrameLayout,
54
+ val opaqueBackground: Boolean,
55
+ var generation: Int = -1,
56
+ var projection: NativeUIProjection? = null,
57
+ var projectionIdentity: JSObject? = null,
58
+ var projectionVisible: Boolean = true,
59
+ var deferredPlacement: JSONObject? = null,
60
+
61
+ ) {
62
+ var placementSequence: Long = -1
63
+ }
64
+
65
+ private data class PendingLoad(
66
+ val call: PluginCall,
67
+ val feedId: String,
68
+ val token: Any = Any(),
69
+ )
70
+
71
+ private val states = mutableMapOf<String, NativeAdState>()
72
+ private val pendingLoads = mutableMapOf<String, PendingLoad>()
73
+ private val feedSessions = NativeAdFeedSessions()
74
+ private val snapshotCapturePending = AtomicBoolean(false)
75
+
76
+ fun startFeed(call: PluginCall) {
77
+ val feedId = requiredString(call, "feedId") ?: return
78
+ val sessionId = requiredString(call, "sessionId") ?: return
79
+ activitySupplier.get().runOnUiThread {
80
+ if (!feedSessions.isCurrent(feedId, sessionId)) clearFeed(feedId)
81
+ if (!feedSessions.start(feedId, sessionId)) {
82
+ call.reject("At most two native ad feeds can be active")
83
+ return@runOnUiThread
84
+ }
85
+ call.resolve()
86
+ }
87
+ }
88
+
89
+ fun destroyFeed(call: PluginCall) {
90
+ val feedId = requiredString(call, "feedId") ?: return
91
+ val sessionId = requiredString(call, "sessionId") ?: return
92
+ activitySupplier.get().runOnUiThread {
93
+ if (feedSessions.isCurrent(feedId, sessionId)) {
94
+ clearFeed(feedId)
95
+ feedSessions.remove(feedId)
96
+ }
97
+ call.resolve()
98
+ }
99
+ }
100
+
101
+ fun load(call: PluginCall) {
102
+ val feedId = requiredString(call, "feedId") ?: return
103
+ val sessionId = requiredString(call, "sessionId") ?: return
104
+ val slotKey = requiredString(call, "slotKey") ?: return
105
+ val options = AdOptions.getFactory().createGenericOptions(call, NATIVE_TESTER_ID)
106
+ val adId = if (options.isTesting) NATIVE_TESTER_ID else requiredString(call, "adId") ?: return
107
+ val template = call.getString("template", "medium") ?: "medium"
108
+ val style = call.getObject("style", JSObject()) ?: JSObject()
109
+ val stateKey = stateKey(feedId, slotKey)
110
+ val finished = AtomicBoolean(false)
111
+ val pendingLoad = PendingLoad(call, feedId)
112
+
113
+ activitySupplier.get().runOnUiThread {
114
+ if (!isCurrentSession(feedId, sessionId)) {
115
+ call.reject("Native ad feed session is no longer active")
116
+ return@runOnUiThread
117
+ }
118
+ destroyState(stateKey)
119
+ cancelPendingLoad(stateKey)
120
+ val feedAdCount = states.values.count { it.feedId == feedId } + pendingLoads.values.count { it.feedId == feedId }
121
+ if (feedAdCount >= MAX_ADS_PER_FEED) {
122
+ call.reject("At most three native ads can be active in a feed")
123
+ return@runOnUiThread
124
+ }
125
+ pendingLoads[stateKey] = pendingLoad
126
+ val adLoader = AdLoader.Builder(contextSupplier.get(), adId)
127
+ .forNativeAd { nativeAd ->
128
+ handleLoadedAd(
129
+ nativeAd = nativeAd,
130
+ adId = adId,
131
+ stateKey = stateKey,
132
+ pendingLoad = pendingLoad,
133
+ finished = finished,
134
+ feedId = feedId,
135
+ sessionId = sessionId,
136
+ slotKey = slotKey,
137
+ template = template,
138
+ style = style,
139
+ )
140
+ }
141
+ .withNativeAdOptions(
142
+ NativeAdOptions.Builder()
143
+ .setAdChoicesPlacement(NativeAdOptions.ADCHOICES_TOP_RIGHT)
144
+ .build(),
145
+ )
146
+ .withAdListener(adListener(stateKey, pendingLoad, finished, feedId, sessionId, slotKey))
147
+ .build()
148
+
149
+ adLoader.loadAd(RequestHelper.createRequest(options))
150
+ }
151
+ }
152
+
153
+ // WebMessageListener delivers on the UI thread; lifecycle calls still use PluginCall.
154
+ fun updateDirectPlacements(raw: String?) {
155
+ try {
156
+ applyPlacements(JSONObject(raw ?: return))
157
+ } catch (_: JSONException) {
158
+ // Ignore malformed messages without affecting another feed.
159
+ }
160
+ }
161
+
162
+ fun updatePlacements(call: PluginCall) {
163
+ requiredString(call, "feedId") ?: return
164
+ requiredString(call, "sessionId") ?: return
165
+ activitySupplier.get().runOnUiThread {
166
+ applyPlacements(call.data) { hidden ->
167
+ if (hidden) call.resolve() else call.reject("Native ad projection did not finish hiding")
168
+ }
169
+ }
170
+ }
171
+
172
+ private fun applyPlacements(batch: JSONObject, complete: ((Boolean) -> Unit)? = null) {
173
+ val feedId = batch.optString("feedId")
174
+ val sessionId = batch.optString("sessionId")
175
+ if (!isCurrentSession(feedId, sessionId) || !feedSessions.accepts(feedId, batch.optLong("sequence", -1L))) { complete?.invoke(true); return }
176
+ val sequence = batch.optLong("sequence", -1L)
177
+ val eligible = states.filter { it.value.feedId == feedId && it.value.placementSequence < sequence }.keys.toSet()
178
+ val shown = mutableSetOf<String>()
179
+ val retained = mutableSetOf<String>()
180
+ val placements = batch.optJSONArray("placements") ?: JSArray()
181
+ repeat(placements.length()) { index ->
182
+ try {
183
+ val placement = placements.getJSONObject(index)
184
+ val key = stateKey(feedId, placement.optString("slotKey"))
185
+ if (key in eligible) {
186
+ if (placement.optBoolean("retain", false)) {
187
+ val state = states[key]
188
+ if (state != null && placement.optString("feedId") == feedId &&
189
+ placement.optInt("generation", -1) == state.generation && placement.optBoolean("visible", false) &&
190
+ state.projectionVisible && state.clippingContainer.visibility == View.VISIBLE) {
191
+ shown.add(key)
192
+ retained.add(key)
193
+ }
194
+ } else applyPlacement(feedId, placement)?.let { shown.add(it) }
195
+ }
196
+ } catch (_: JSONException) {
197
+ // Invalid entries fail closed below.
198
+ }
199
+ }
200
+ val hiding = mutableListOf<NativeUIProjection>()
201
+ // Retain carries no new geometry. A delayed retain must not supersede
202
+ // the final position arriving independently through the Engine channel.
203
+ (eligible - retained).forEach { states[it]?.placementSequence = sequence }
204
+ states.filter { it.key in eligible && it.key !in shown }.values.forEach {
205
+ it.deferredPlacement = null
206
+ it.clippingContainer.visibility = View.GONE
207
+ if (it.projectionVisible || complete != null) it.projection?.let(hiding::add)
208
+ if (it.projectionVisible) {
209
+ it.projectionVisible = false
210
+ it.projectionIdentity?.let { event -> notifyListeners("nativeAdProjectionHidden", event) }
211
+ }
212
+ }
213
+ if (hiding.isEmpty()) complete?.invoke(true)
214
+ else {
215
+ var remaining = hiding.size
216
+ var succeeded = true
217
+ hiding.forEach { projection -> projection.hide { result ->
218
+ succeeded = succeeded && result
219
+ if (--remaining == 0) complete?.invoke(succeeded)
220
+ } }
221
+ }
222
+ }
223
+
224
+ fun remove(call: PluginCall) {
225
+ val feedId = requiredString(call, "feedId") ?: return
226
+ val sessionId = requiredString(call, "sessionId") ?: return
227
+ val slotKey = requiredString(call, "slotKey") ?: return
228
+ activitySupplier.get().runOnUiThread {
229
+ if (isCurrentSession(feedId, sessionId)) {
230
+ val key = stateKey(feedId, slotKey)
231
+ cancelPendingLoad(key)
232
+ destroyState(key)
233
+ }
234
+ call.resolve()
235
+ }
236
+ }
237
+
238
+ fun destroyAll() {
239
+ pendingLoads.keys.toList().forEach(::cancelPendingLoad)
240
+ states.keys.toList().forEach(::destroyState)
241
+ feedSessions.clear()
242
+ }
243
+
244
+ private fun handleLoadedAd(
245
+ nativeAd: NativeAd,
246
+ adId: String,
247
+ stateKey: String,
248
+ pendingLoad: PendingLoad,
249
+ finished: AtomicBoolean,
250
+ feedId: String,
251
+ sessionId: String,
252
+ slotKey: String,
253
+ template: String,
254
+ style: JSObject,
255
+ ) {
256
+ if (pendingLoads[stateKey] !== pendingLoad || !finished.compareAndSet(false, true)) {
257
+ nativeAd.destroy()
258
+ return
259
+ }
260
+ pendingLoads.remove(stateKey)
261
+ if (template == "small" && nativeAd.mediaContent?.hasVideoContent() == true) {
262
+ nativeAd.destroy()
263
+ val message = "Small native ad template does not support video ads; use Medium"
264
+ val event = identity(feedId, sessionId, slotKey).apply {
265
+ put("code", -1)
266
+ put("message", message)
267
+ }
268
+ notifyListeners(NativeAdPluginEvents.FAILED_TO_LOAD, event)
269
+ pendingLoad.call.reject(message, "-1", event)
270
+ return
271
+ }
272
+ val adView = PluginNativeAdView.create(contextSupplier.get(), nativeAd, template, style)
273
+ val clippingContainer = FrameLayout(contextSupplier.get()).apply {
274
+ clipChildren = true
275
+ clipToPadding = true
276
+ visibility = View.GONE
277
+ addView(adView)
278
+ }
279
+ val overlayParent = overlayParent()
280
+ if (overlayParent == null) {
281
+ adView.destroy()
282
+ nativeAd.destroy()
283
+ pendingLoad.call.reject("AdMob WebView parent is unavailable")
284
+ return
285
+ }
286
+
287
+ val backgroundColor = (adView.background as? GradientDrawable)?.color?.defaultColor
288
+ val state = NativeAdState(
289
+ feedId, template == "small", pendingLoad.token, nativeAd, adView, clippingContainer,
290
+ backgroundColor != null && Color.alpha(backgroundColor) == 255,
291
+ )
292
+ states[stateKey] = state
293
+ overlayParent.addView(clippingContainer)
294
+ pendingLoad.call.getString("projectionSelector")?.let { selector ->
295
+ val event = identity(feedId, sessionId, slotKey).apply { put("loadId", java.util.UUID.randomUUID().toString()) }
296
+ state.projectionIdentity = event
297
+ val webView = webViewSupplier.get() as? WebView
298
+ if (webView != null) webView.post {
299
+ if (states[stateKey] !== state) return@post
300
+ state.projection = runCatching {
301
+ NativeUIProjection(
302
+ window = activitySupplier.get().window,
303
+ webView = webView,
304
+ plugin = "AdMob",
305
+ projectionSelector = selector,
306
+ receiveListeners = NativeUIProjection.ReceiveListeners(
307
+ update = listOf("nativeAdProjectionUpdated"),
308
+ projection = NativeUIProjection.ReceiveListeners.Projection(
309
+ native = listOf("nativeAdProjectionShown"), none = listOf("nativeAdProjectionHidden")
310
+ ),
311
+ match = mapOf("feedId" to feedId, "sessionId" to sessionId, "slotKey" to slotKey, "loadId" to event.optString("loadId"))
312
+ ),
313
+ failedFallback = NativeUIProjection.FailedFallback.NATIVE,
314
+ captureMode = NativeUIProjection.CaptureMode.VIEW,
315
+ updatePlacement = { placement ->
316
+ check(states[stateKey] === state && isCurrentSession(feedId, sessionId)) { "Native ad owner changed" }
317
+ check(placement.optString("feedId") == feedId && placement.optString("sessionId") == sessionId &&
318
+ placement.optString("slotKey") == slotKey) { "Native ad placement identity changed" }
319
+ check(placement.optInt("generation", -1) >= state.generation &&
320
+ placement.optLong("sequence", -1L) > state.placementSequence && state.projectionVisible) {
321
+ "Native ad placement is stale or hidden"
322
+ }
323
+ if (!placement.optBoolean("visible", false)) {
324
+ state.placementSequence = placement.optLong("sequence")
325
+ state.deferredPlacement = null
326
+ state.clippingContainer.visibility = View.GONE
327
+ state.projectionVisible = false
328
+ state.projection?.hide()
329
+ error("Native ad placement is not visible")
330
+ }
331
+ check(applyPlacement(feedId, placement, defer = false) == stateKey) { "Native ad placement is invalid" }
332
+ state.placementSequence = placement.optLong("sequence")
333
+ },
334
+ beforeNativeReveal = {
335
+ val placement = state.deferredPlacement
336
+ state.deferredPlacement = null
337
+ if (states[stateKey] === state && placement != null) applyPlacement(feedId, placement, defer = false)
338
+ },
339
+ canCapture = { states[stateKey] === state && state.opaqueBackground &&
340
+ nativeAd.mediaContent?.hasVideoContent() != true && isSnapshotSizeSupported(adView.width, adView.height) },
341
+ view = { if (states[stateKey] === state) adView else null }
342
+ )
343
+ }.onFailure { android.util.Log.w("AdMob", "Native ad projection unavailable; retaining native tracking", it) }.getOrNull()
344
+ }
345
+ }
346
+ nativeAd.setOnPaidEventListener { adValue ->
347
+ if (states[stateKey] !== state) return@setOnPaidEventListener
348
+ val responseInfo = nativeAd.responseInfo
349
+ val data = AdMobRevenueData(
350
+ adValue,
351
+ adId,
352
+ responseInfo?.mediationAdapterClassName ?: "",
353
+ responseInfo?.responseId ?: "",
354
+ ).apply {
355
+ put("feedId", feedId)
356
+ put("slotKey", slotKey)
357
+ put("sessionId", sessionId)
358
+ }
359
+ notifyListeners(NativeAdPluginEvents.AD_PAID, data)
360
+ }
361
+
362
+ notifyListeners(NativeAdPluginEvents.LOADED, identity(feedId, sessionId, slotKey))
363
+ pendingLoad.call.resolve()
364
+ }
365
+
366
+ private fun adListener(
367
+ stateKey: String,
368
+ pendingLoad: PendingLoad,
369
+ finished: AtomicBoolean,
370
+ feedId: String,
371
+ sessionId: String,
372
+ slotKey: String,
373
+ ): AdListener = object : AdListener() {
374
+ override fun onAdFailedToLoad(error: LoadAdError) {
375
+ if (pendingLoads[stateKey] !== pendingLoad) return
376
+ pendingLoads.remove(stateKey)
377
+ val event = identity(feedId, sessionId, slotKey).apply {
378
+ put("code", error.code)
379
+ put("message", error.message)
380
+ }
381
+ notifyListeners(NativeAdPluginEvents.FAILED_TO_LOAD, event)
382
+ if (finished.compareAndSet(false, true)) {
383
+ pendingLoad.call.reject(error.message, error.code.toString(), event)
384
+ }
385
+ }
386
+
387
+ override fun onAdClicked() = notifyIfCurrent(NativeAdPluginEvents.CLICKED)
388
+
389
+ override fun onAdImpression() = notifyIfCurrent(NativeAdPluginEvents.IMPRESSION)
390
+
391
+ override fun onAdOpened() = notifyIfCurrent(NativeAdPluginEvents.OPENED)
392
+
393
+ override fun onAdClosed() = notifyIfCurrent(NativeAdPluginEvents.CLOSED)
394
+
395
+ private fun notifyIfCurrent(eventName: String) {
396
+ if (isCurrent(stateKey, pendingLoad.token)) {
397
+ notifyListeners(eventName, identity(feedId, sessionId, slotKey))
398
+ }
399
+ }
400
+ }
401
+
402
+ private fun applyPlacement(expectedFeedId: String, placement: JSONObject, defer: Boolean = true): String? {
403
+ val density = contextSupplier.get().resources.displayMetrics.density
404
+ val value = NativeAdPlacementValue.parse(expectedFeedId, placement, density) ?: return null
405
+ val state = states[stateKey(value.feedId, value.slotKey)] ?: return null
406
+ if (value.generation < state.generation) return null
407
+ val minimumWidth = (if (state.isSmall) 120 else 144) * density
408
+ val minimumHeight = (if (state.isSmall) 120 else 300) * density
409
+ if (value.rectWidth < minimumWidth.roundToInt() || value.rectHeight < minimumHeight.roundToInt()) return null
410
+
411
+ if (defer && state.projection?.isProjecting == true &&
412
+ state.adView.width == value.rectWidth && state.adView.height == value.rectHeight) {
413
+ state.deferredPlacement = JSONObject(placement.toString())
414
+ state.generation = value.generation
415
+ return stateKey(value.feedId, value.slotKey)
416
+ }
417
+ state.deferredPlacement = null
418
+ val resized = state.adView.width != value.rectWidth || state.adView.height != value.rectHeight
419
+
420
+ if (!state.isSmall) {
421
+ val adView = state.adView
422
+ if (adView.isLayoutRequested || adView.measuredWidth != value.rectWidth || adView.measuredHeight != value.rectHeight) {
423
+ adView.measure(
424
+ View.MeasureSpec.makeMeasureSpec(value.rectWidth, View.MeasureSpec.EXACTLY),
425
+ View.MeasureSpec.makeMeasureSpec(value.rectHeight, View.MeasureSpec.EXACTLY),
426
+ )
427
+ adView.layout(0, 0, value.rectWidth, value.rectHeight)
428
+ }
429
+ val media = adView.mediaView ?: return null
430
+ if (media.measuredWidth < 120 * density || media.measuredHeight < 120 * density) return null
431
+ }
432
+
433
+ val webView = webViewSupplier.get() ?: return null
434
+ val wasVisible = state.clippingContainer.visibility == View.VISIBLE
435
+ val clipParams = state.clippingContainer.layoutParams
436
+ if (clipParams.width != value.clipWidth || clipParams.height != value.clipHeight) {
437
+ state.clippingContainer.layoutParams = clipParams.apply {
438
+ width = value.clipWidth
439
+ height = value.clipHeight
440
+ }
441
+ }
442
+ state.clippingContainer.x = webView.x + value.clipX
443
+ state.clippingContainer.y = webView.y + value.clipY
444
+ val adParams = state.adView.layoutParams
445
+ if (adParams.width != value.rectWidth || adParams.height != value.rectHeight) {
446
+ state.adView.layoutParams = FrameLayout.LayoutParams(value.rectWidth, value.rectHeight)
447
+ }
448
+ state.adView.x = (value.rectX - value.clipX).toFloat()
449
+ state.adView.y = (value.rectY - value.clipY).toFloat()
450
+ state.generation = value.generation
451
+ state.clippingContainer.visibility = View.VISIBLE
452
+ if (!wasVisible) state.clippingContainer.bringToFront()
453
+ if (!state.projectionVisible) {
454
+ state.projection?.show()
455
+ state.projectionVisible = true
456
+ state.projectionIdentity?.let { notifyListeners("nativeAdProjectionShown", it) }
457
+ }
458
+ if (resized) state.projectionIdentity?.let { notifyListeners("nativeAdProjectionUpdated", it) }
459
+ return stateKey(value.feedId, value.slotKey)
460
+ }
461
+
462
+ private fun overlayParent(): ViewGroup? = webViewSupplier.get()?.parent as? ViewGroup
463
+
464
+ // Scroll snapshot proxy. SDK views remain responsible for all ad interactions.
465
+ fun captureSnapshot(call: PluginCall) {
466
+ val feedId = requiredString(call, "feedId") ?: return
467
+ val sessionId = requiredString(call, "sessionId") ?: return
468
+ val slotKey = requiredString(call, "slotKey") ?: return
469
+ activitySupplier.get().runOnUiThread {
470
+ val state = states[stateKey(feedId, slotKey)]
471
+ if (!isCurrentSession(feedId, sessionId) || state == null) {
472
+ call.reject("Snapshots require an active ad")
473
+ return@runOnUiThread
474
+ }
475
+ if (!state.opaqueBackground) {
476
+ call.reject("Snapshot underlays require an opaque native ad background")
477
+ return@runOnUiThread
478
+ }
479
+ if (state.nativeAd.mediaContent?.hasVideoContent() == true) {
480
+ call.reject("Video ads retain native tracking while scrolling")
481
+ return@runOnUiThread
482
+ }
483
+ val view = state.adView
484
+ if (!view.isShown || view.isLayoutRequested || !isSnapshotSizeSupported(view.width, view.height)) {
485
+ call.reject("Native ad is not ready for a bounded snapshot")
486
+ return@runOnUiThread
487
+ }
488
+ // One bitmap/encoder per executor, including concurrent feeds.
489
+ if (!snapshotCapturePending.compareAndSet(false, true)) {
490
+ call.reject("Another native ad snapshot is being encoded")
491
+ return@runOnUiThread
492
+ }
493
+ val started = SystemClock.elapsedRealtimeNanos()
494
+ val drawCpuStart = Debug.threadCpuTimeNanos()
495
+ val bitmap = try {
496
+ Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
497
+ } catch (error: Exception) {
498
+ snapshotCapturePending.set(false)
499
+ call.reject("Could not allocate native ad snapshot", error)
500
+ return@runOnUiThread
501
+ }
502
+ try {
503
+ view.draw(Canvas(bitmap))
504
+ val drawn = SystemClock.elapsedRealtimeNanos()
505
+ val drawCpuMs = (Debug.threadCpuTimeNanos() - drawCpuStart) / 1_000_000.0
506
+ // Compression must not block the UI thread when the next gesture begins.
507
+ Thread {
508
+ try {
509
+ val encodeStart = SystemClock.elapsedRealtimeNanos()
510
+ val encodeCpuStart = Debug.threadCpuTimeNanos()
511
+ val bytes = ByteArrayOutputStream()
512
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes)
513
+ val dataUrl = "data:image/png;base64," + Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)
514
+ val timings = JSObject().apply {
515
+ put("drawMs", (drawn - started) / 1_000_000.0)
516
+ put("drawCpuMs", drawCpuMs)
517
+ put("workerWaitMs", (encodeStart - drawn) / 1_000_000.0)
518
+ put("encodeMs", (SystemClock.elapsedRealtimeNanos() - encodeStart) / 1_000_000.0)
519
+ put("encodeCpuMs", (Debug.threadCpuTimeNanos() - encodeCpuStart) / 1_000_000.0)
520
+ put("bitmapBytes", bitmap.byteCount)
521
+ put("pngBytes", bytes.size())
522
+ }
523
+ call.resolve(JSObject().put("dataUrl", dataUrl).put("timings", timings))
524
+ } catch (error: Exception) {
525
+ call.reject("Could not encode native ad snapshot", error)
526
+ } finally {
527
+ bitmap.recycle()
528
+ snapshotCapturePending.set(false)
529
+ }
530
+ }.start()
531
+ } catch (error: Exception) {
532
+ bitmap.recycle()
533
+ snapshotCapturePending.set(false)
534
+ call.reject("Could not draw native ad snapshot", error)
535
+ }
536
+ }
537
+ }
538
+
539
+ fun prepareSnapshot(call: PluginCall) {
540
+ val feedId = requiredString(call, "feedId") ?: return
541
+ val sessionId = requiredString(call, "sessionId") ?: return
542
+ val slotKey = requiredString(call, "slotKey") ?: return
543
+ activitySupplier.get().runOnUiThread {
544
+ val key = stateKey(feedId, slotKey)
545
+ val state = states[key]
546
+ val webView = webViewSupplier.get() as? WebView
547
+ if (!isCurrentSession(feedId, sessionId) || state == null ||
548
+ webView == null || !webView.isAttachedToWindow || !webView.isShown) {
549
+ call.reject("Native ad snapshot WebView is no longer visible")
550
+ return@runOnUiThread
551
+ }
552
+ val completed = AtomicBoolean(false)
553
+ val handler = Handler(Looper.getMainLooper())
554
+ val timeout = Runnable {
555
+ if (completed.compareAndSet(false, true)) call.reject("Native ad snapshot visual state timed out")
556
+ }
557
+ handler.postDelayed(timeout, 2000)
558
+ try {
559
+ webView.postVisualStateCallback(0, object : WebView.VisualStateCallback() {
560
+ override fun onComplete(requestId: Long) {
561
+ if (!completed.compareAndSet(false, true)) return
562
+ handler.removeCallbacks(timeout)
563
+ if (!isCurrentSession(feedId, sessionId) || states[key] !== state ||
564
+ !webView.isAttachedToWindow || !webView.isShown) {
565
+ call.reject("Native ad snapshot was invalidated")
566
+ return
567
+ }
568
+ // Ready for the next WebView draw, not a claim that pixels have been presented.
569
+ webView.invalidate()
570
+ call.resolve()
571
+ }
572
+ })
573
+ } catch (error: Exception) {
574
+ if (completed.compareAndSet(false, true)) {
575
+ handler.removeCallbacks(timeout)
576
+ call.reject("Could not prepare native ad snapshot", error)
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ private fun destroyState(stateKey: String) {
583
+ val state = states.remove(stateKey) ?: return
584
+ state.projection?.close()
585
+ (state.clippingContainer.parent as? ViewGroup)?.removeView(state.clippingContainer)
586
+ state.adView.destroy()
587
+ state.nativeAd.destroy()
588
+ }
589
+
590
+ private fun cancelPendingLoad(stateKey: String) {
591
+ pendingLoads.remove(stateKey)?.call?.reject("Native ad load was cancelled")
592
+ }
593
+
594
+ private fun isCurrent(stateKey: String, loadToken: Any): Boolean = states[stateKey]?.loadToken === loadToken
595
+
596
+ private fun isCurrentSession(feedId: String, sessionId: String): Boolean = feedSessions.isCurrent(feedId, sessionId)
597
+
598
+ private fun clearFeed(feedId: String) {
599
+ pendingLoads.filterValues { it.feedId == feedId }.keys.toList().forEach(::cancelPendingLoad)
600
+ states.filterValues { it.feedId == feedId }.keys.toList().forEach(::destroyState)
601
+ }
602
+
603
+ private fun requiredString(call: PluginCall, name: String): String? {
604
+ val value = call.getString(name)?.trim()
605
+ if (value.isNullOrEmpty()) {
606
+ call.reject("$name must not be empty")
607
+ return null
608
+ }
609
+ return value
610
+ }
611
+
612
+ private fun stateKey(feedId: String, slotKey: String): String = "$feedId\u0000$slotKey"
613
+
614
+ private fun identity(feedId: String, sessionId: String, slotKey: String): JSObject = JSObject().apply {
615
+ put("feedId", feedId)
616
+ put("sessionId", sessionId)
617
+ put("slotKey", slotKey)
618
+ }
619
+
620
+ companion object {
621
+ const val NATIVE_TESTER_ID = "ca-app-pub-3940256099942544/2247696110"
622
+ private const val MAX_ADS_PER_FEED = 3
623
+ internal fun isSnapshotSizeSupported(width: Int, height: Int): Boolean =
624
+ width > 0 && height > 0 && width.toLong() * height <= 2_000_000
625
+ }
626
+ }
@@ -0,0 +1,35 @@
1
+ package com.getcapacitor.community.admob.nativeads
2
+
3
+ internal class NativeAdFeedSessions {
4
+ private val sessions = mutableMapOf<String, String>()
5
+ private val placementSequences = mutableMapOf<String, Long>()
6
+
7
+ fun start(feedId: String, sessionId: String): Boolean {
8
+ if (feedId !in sessions && sessions.size >= MAX_FEEDS) return false
9
+ sessions[feedId] = sessionId
10
+ placementSequences[feedId] = -1
11
+ return true
12
+ }
13
+
14
+ fun isCurrent(feedId: String, sessionId: String): Boolean = sessions[feedId] == sessionId
15
+
16
+ fun accepts(feedId: String, sequence: Long): Boolean {
17
+ if (sequence <= placementSequences.getOrDefault(feedId, -1)) return false
18
+ placementSequences[feedId] = sequence
19
+ return true
20
+ }
21
+
22
+ fun remove(feedId: String) {
23
+ sessions.remove(feedId)
24
+ placementSequences.remove(feedId)
25
+ }
26
+
27
+ fun clear() {
28
+ sessions.clear()
29
+ placementSequences.clear()
30
+ }
31
+
32
+ private companion object {
33
+ const val MAX_FEEDS = 2
34
+ }
35
+ }