@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.
- package/CapacitorCommunityAdmob.podspec +1 -1
- package/Package.swift +1 -1
- package/README.md +8 -7
- package/android/build.gradle +5 -1
- package/android/src/main/java/com/getcapacitor/community/admob/AdMob.java +88 -0
- package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdExecutor.kt +626 -0
- package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdFeedSessions.kt +35 -0
- package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPlacementValue.kt +44 -0
- package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdPluginEvents.kt +11 -0
- package/android/src/main/java/com/getcapacitor/community/admob/nativeads/PluginNativeAdView.kt +187 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +5 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/native-ads/index.d.ts +6 -0
- package/dist/esm/native-ads/index.js +4 -0
- package/dist/esm/native-ads/index.js.map +1 -0
- package/dist/esm/native-ads/native-ad-definitions.interface.d.ts +20 -0
- package/dist/esm/native-ads/native-ad-definitions.interface.js +2 -0
- package/dist/esm/native-ads/native-ad-definitions.interface.js.map +1 -0
- package/dist/esm/native-ads/native-ad-element.d.ts +9 -0
- package/dist/esm/native-ads/native-ad-element.js +53 -0
- package/dist/esm/native-ads/native-ad-element.js.map +1 -0
- package/dist/esm/native-ads/native-ad-event.interface.d.ts +5 -0
- package/dist/esm/native-ads/native-ad-event.interface.js +2 -0
- package/dist/esm/native-ads/native-ad-event.interface.js.map +1 -0
- package/dist/esm/native-ads/native-ad-feed.d.ts +72 -0
- package/dist/esm/native-ads/native-ad-feed.js +643 -0
- package/dist/esm/native-ads/native-ad-feed.js.map +1 -0
- package/dist/esm/native-ads/native-ad-geometry.d.ts +8 -0
- package/dist/esm/native-ads/native-ad-geometry.js +92 -0
- package/dist/esm/native-ads/native-ad-geometry.js.map +1 -0
- package/dist/esm/native-ads/native-ad-options.interface.d.ts +40 -0
- package/dist/esm/native-ads/native-ad-options.interface.js +2 -0
- package/dist/esm/native-ads/native-ad-options.interface.js.map +1 -0
- package/dist/esm/native-ads/native-ad-placement.interface.d.ts +28 -0
- package/dist/esm/native-ads/native-ad-placement.interface.js +2 -0
- package/dist/esm/native-ads/native-ad-placement.interface.js.map +1 -0
- package/dist/esm/native-ads/native-ad-plugin-events.enum.d.ts +9 -0
- package/dist/esm/native-ads/native-ad-plugin-events.enum.js +11 -0
- package/dist/esm/native-ads/native-ad-plugin-events.enum.js.map +1 -0
- package/dist/esm/native-ads/native-ad-style.interface.d.ts +30 -0
- package/dist/esm/native-ads/native-ad-style.interface.js +2 -0
- package/dist/esm/native-ads/native-ad-style.interface.js.map +1 -0
- package/dist/esm/native-ads/native-ad-template.enum.d.ts +11 -0
- package/dist/esm/native-ads/native-ad-template.enum.js +13 -0
- package/dist/esm/native-ads/native-ad-template.enum.js.map +1 -0
- package/dist/esm/web.d.ts +9 -1
- package/dist/esm/web.js +15 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +883 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +883 -1
- package/dist/plugin.js.map +1 -1
- package/docs/configuration.md +2 -2
- package/docs/consent.md +6 -6
- package/docs/events.md +3 -2
- package/docs/migration.md +22 -2
- package/docs/native-ads.md +187 -0
- package/docs/rewarded.md +13 -0
- package/ios/Sources/AdMobPlugin/AdMobPlugin.swift +103 -41
- package/ios/Sources/AdMobPlugin/AppOpen/AppOpenAdPluginEvents.swift +3 -0
- package/ios/Sources/AdMobPlugin/Banner/BannerAdPluginEvents.swift +3 -0
- package/ios/Sources/AdMobPlugin/Banner/BannerAdSizeEnum.swift +3 -0
- package/ios/Sources/AdMobPlugin/Banner/BannerExecutor.swift +4 -4
- package/ios/Sources/AdMobPlugin/Helper/AuthorizationStatusEnum.swift +3 -0
- package/ios/Sources/AdMobPlugin/Interstitial/AdInterstitialExecutor.swift +3 -0
- package/ios/Sources/AdMobPlugin/Interstitial/InterstitialAdPluginEvents.swift +3 -0
- package/ios/Sources/AdMobPlugin/NativeAds/NativeAdExecutor.swift +354 -0
- package/ios/Sources/AdMobPlugin/NativeAds/NativeAdFeedSessions.swift +34 -0
- package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPlacementValue.swift +50 -0
- package/ios/Sources/AdMobPlugin/NativeAds/NativeAdPluginEvents.swift +11 -0
- package/ios/Sources/AdMobPlugin/NativeAds/NativeAdScrollTracker.swift +66 -0
- package/ios/Sources/AdMobPlugin/NativeAds/NativeAdValues.swift +27 -0
- package/ios/Sources/AdMobPlugin/NativeAds/PluginNativeAdView.swift +187 -0
- package/ios/Sources/AdMobPlugin/Rewarded/AdRewardExecutor.swift +3 -0
- package/ios/Sources/AdMobPlugin/Rewarded/RewardAdPluginEvents.swift +3 -0
- package/ios/Sources/AdMobPlugin/RewardedInterstitial/AdRewardInterstitialExecutor.swift +3 -0
- package/ios/Sources/AdMobPlugin/RewardedInterstitial/RewardInterstitialAdPluginEvents.swift +3 -0
- package/ios/Tests/AdMobPluginTests/AdMobPluginTests.swift +82 -10
- package/ios/Tests/AdMobPluginTests/NativeAdScrollTrackerTests.swift +47 -0
- 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
|
+
}
|
package/android/src/main/java/com/getcapacitor/community/admob/nativeads/NativeAdFeedSessions.kt
ADDED
|
@@ -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
|
+
}
|