Trusted Arbiter
Compare CloudX bids with supported third-party bids in Android apps
Trusted Arbiter compares a loaded CloudX bid with supported third-party bids and returns the selected platform. CloudX SDK versions 4.1.0 and later support CloudX, Unity LevelPlay, and PubMatic bid inputs. CloudX SDK 4.2.0 and later also supports custom publisher-supplied bid inputs.
Why use Trusted Arbiter?
When your app loads ads from several platforms, it needs a way to choose which ad to show. Trusted Arbiter provides a shared comparison API for supported bids.
Less comparison logic to maintain
Submit the supported bids to Trusted Arbiter and use the returned platform to select the ad. Your app continues to manage partner SDK integration, ad loading, and display.
Use available bid values
Trusted Arbiter can compare actual bid values where available. Some demand uses estimated prices. See the pricing guidance below for each supported input.
Demo App
The CloudX Android sample app runs this page’s cycle for interstitials. CloudX and AdMob load in parallel, the loaded ads become bids, CloudX.arbiter() picks the winner, and the winner is shown from a stored result so the show path makes no network call.
ArbiterInterstitialController.kt
The whole load, arbitrate and show cycle, with both SDKs’ calls and the AdMob revenue report in one file. Rewarded follows this file with the rewarded calls substituted.
ArbiterActivity.kt
Brings up both SDKs, waits up to 15 seconds for CloudX, retries with a 2 to 60 second backoff and wires the Show button. This is demo-only layout; take the rules, not the file.
DemoConfig.kt
App key and ad unit IDs. This is the first file to edit when you run the demo yourself.
The controller logs through the demo’s DemoLog; swap in your own logging when you copy it. The demo ships Google’s AdMob test units, which report revenue of 0. CloudX does not keep a price of 0, so the AdMob bid has no comparable price while you test. Point the demo at a paying unit to watch prices compete, or give the AdMob bid a manual price for one launch as the demo’s README shows.
When to run the arbiter
Run the arbiter when the candidate ads finish loading — never on the show path. The arbiter call is a network round trip, so a user who taps a button that shows an ad must never be left waiting on it. Arbitrate ahead of the placement and the decision is already made when the placement arrives.
Fullscreen formats (interstitial, rewarded, app open): prepare ahead
- Load all candidates in parallel.
- When every candidate has settled — loaded or failed — run the arbiter and store the result.
- At the placement, show the stored winner immediately. No arbiter call here.
- Start the cycle again once the ad has been shown and dismissed, fails to display, or a candidate expires.
private var nextWinner: CloudXArbiterResult? = null
// Runs as soon as the candidates have loaded, ahead of the placement.
private fun prepareWinner() {
val configuration = CloudXArbiterConfiguration.builder(bids).build()
CloudX.arbiter(configuration, object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
nextWinner = result
}
})
}
// Runs at the placement. No network call here.
fun showInterstitial(activity: Activity) {
when (nextWinner?.platform) {
CloudXArbiterPlatform.CLOUDX -> cloudXInterstitial.show(activity)
CloudXArbiterPlatform.LEVELPLAY -> levelPlayInterstitial.showAd(activity)
else -> { } // no winner prepared; continue without an ad
}
nextWinner = null
}If the placement arrives before a winner is stored, either continue the app flow without an ad or show the single candidate that did load. Showing a lone candidate is an acceptable degraded path, but it must never be the primary one — an arbitrated winner earns more than an unarbitrated fill.
View formats (banner, MREC, native): arbitrate, then render
View formats have no user-initiated show, so nothing is waiting on the round trip. Here it is correct to attach or render the winner directly inside the completion callback: the arbiter completing is the trigger to show. Only the winner’s view or assets may ever be attached — see Banner and MREC arbitration for the attachment and refresh rules.
Supported ad formats
Trusted Arbiter is format-agnostic: arbiter payloads are attached to CloudX ads of every format, and any loaded CloudX ad can be compared against the supplied third-party bids.
- Interstitial, rewarded, and app open (fullscreen formats) prepare a winner ahead of the placement — see When to run the arbiter — and follow the step-by-step walkthrough and controller pattern shown later on this page. Worked examples: Interstitial example and App Open example. Rewarded follows the interstitial pattern unchanged — only the ad type differs.
- Banner, MREC, and native (view formats) arbitrate and then render. They need the additional handling covered in Banner and MREC arbitration below, since a losing bid’s view must never be attached to the view hierarchy. Worked examples: Banner example and Native example.
Basic API
Create bid candidates from loaded ads, then pass them to CloudX.arbiter().
// cloudXAd is the CloudXAd from a CloudX onAdLoaded callback.
// levelPlayAdInfo is the Unity LevelPlay ad info object.
// pobBid is the PubMatic/OpenWrap bid object.
val bids = listOf(
CloudXArbiterBid.cloudX(cloudXAd),
CloudXArbiterBid.levelPlay(
networkName = levelPlayAdInfo.adNetwork,
revenue = levelPlayAdInfo.revenue,
precision = levelPlayAdInfo.precision,
),
CloudXArbiterBid.pubmatic(
price = pobBid.price,
partnerName = pobBid.partnerName,
)
)
val configuration = CloudXArbiterConfiguration.builder(bids).build()
CloudX.arbiter(configuration, object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
Log.d("CloudX", "Selected platform: ${result.platform.name}")
}
})CloudXArbiterBid.cloudX() accepts the CloudXAd object from a CloudX load callback. levelPlay() accepts Unity LevelPlay ad info values. pubmatic() accepts a PubMatic OpenWrap bid price and optional partner name. partnerName and the extras map are optional on the bid factories. onCompleted() runs on the main thread. For fullscreen formats, store the result there and show it at the placement — see When to run the arbiter; view formats may render the winner directly from it.
result.platform is CloudXArbiterPlatform.CLOUDX, LEVELPLAY, or PUBMATIC for the selected platform, or CloudXArbiterPlatform.NONE when no winner could be selected — either because no bids were supplied, or because the fallback found no candidate carrying a locally comparable price.
AdMob and Google Ad Manager
CloudX compares a loaded CloudX ad with a loaded AdMob or Google Ad Manager ad. AdMob and Ad Manager are separate demand sources, so both may bid in the same arbitration. This applies to publisher-managed mediation setups, including accounts described commercially as AdMob Pro; there is no separate AdMob Pro SDK API.
Google demand does not normally reveal the price of a loaded ad before it is shown, so there is no pre-bid price for the arbiter to compare against CloudX’s bid. CloudX prices the bid automatically from your historical Google performance, so you do not supply a price and no pre-bid pricing API is required. The revenue reporting below feeds that history. See how AdMob/GAM estimated pricing works.
If your AdMob account exposes impression-level revenue data pre-bid, you can supply that exact price yourself instead of using the estimate — see Manual input with pre-bid ILRD below.
Report Google paid events back to CloudX (required)
Forwarding Google’s paid events is a required part of the Trusted Arbiter AdMob and Ad Manager integration, not an optional analytics extra. After you show an AdMob or Ad Manager ad that won an arbitration, hand Google’s impression-level revenue for that ad to the CloudX SDK. Without it, CloudX never learns what Google demand actually paid, and the estimates it produces for future arbitrations degrade.
Google delivers this revenue per ad through OnPaidEventListener — there is no global impression bus — so attach the listener to every arbitrated Google ad and forward each event through CloudX.reportRevenueData(). AdValue.valueMicros is in micro-units of the reported currency, so divide by 1_000_000.0 to get revenue for one impression. Use CloudXRevenuePlatform.ADMOB for AdMob ads and CloudXRevenuePlatform.GAM for Ad Manager ads.
private fun Int.toCloudXRevenuePrecision(): CloudXRevenuePrecision = when (this) {
AdValue.PrecisionType.PRECISE -> CloudXRevenuePrecision.EXACT
AdValue.PrecisionType.ESTIMATED -> CloudXRevenuePrecision.ESTIMATED
AdValue.PrecisionType.PUBLISHER_PROVIDED -> CloudXRevenuePrecision.PUBLISHER_DEFINED
else -> CloudXRevenuePrecision.UNDEFINED
}
private fun reportGooglePaidEvent(
platform: CloudXRevenuePlatform,
adValue: AdValue,
adFormat: String,
adUnitId: String,
responseInfo: ResponseInfo?,
): Boolean {
val servedBy = responseInfo?.loadedAdapterResponseInfo
return CloudX.reportRevenueData(
CloudXRevenueData.builder(
platform = platform,
revenue = adValue.valueMicros / 1_000_000.0,
adFormat = adFormat,
)
.currencyCode(adValue.currencyCode)
.precision(adValue.precisionType.toCloudXRevenuePrecision())
.networkName(servedBy?.adSourceName)
.adUnitId(adUnitId)
.thirdPartyAdPlacementId(servedBy?.adSourceInstanceName)
.build(),
)
}
// Attach the listener to the arbitrated Google ad before you show it.
adMobInterstitial.setOnPaidEventListener { adValue ->
reportGooglePaidEvent(
platform = CloudXRevenuePlatform.ADMOB,
adValue = adValue,
adFormat = "interstitial",
adUnitId = adMobAdUnitId,
responseInfo = adMobInterstitial.responseInfo,
)
}
// An Ad Manager ad reports the same way, with the GAM platform.
adManagerInterstitial.setOnPaidEventListener { adValue ->
reportGooglePaidEvent(
platform = CloudXRevenuePlatform.GAM,
adValue = adValue,
adFormat = "interstitial",
adUnitId = adManagerAdUnitId,
responseInfo = adManagerInterstitial.responseInfo,
)
}reportRevenueData() returns true when the event is accepted into the CloudX revenue pipeline, and false if the SDK is not initialized or server-side revenue tracking is disabled. For banner and MREC, attach the listener once to the AdView — Google re-fires paid events on the same view — and pass "banner" or "mrec" as the format. See Publisher-Reported Revenue Data for the full field reference and the other supported platforms.
Create the bid from the ad unit id of the ad you loaded:
// networkName is optional; pass the winning ad source when you know it, e.g.
// responseInfo?.loadedAdapterResponseInfo?.adSourceName.
val adMobBid = CloudXArbiterBid.adMob(
adUnitId = adMobAdUnitId,
networkName = adMobNetworkName ?: "admob",
manualRevenuePerImpressionUSD = null,
extras = emptyMap(),
)
// An Ad Manager ad unit id takes the form /NNNNNNN/placement/name.
val adManagerBid = CloudXArbiterBid.gam(
adUnitId = "/21775744923/example/interstitial",
networkName = "gam",
manualRevenuePerImpressionUSD = null,
extras = emptyMap(),
)
val configuration = CloudXArbiterConfiguration.builder(
listOf(CloudXArbiterBid.cloudX(cloudXAd), adMobBid, adManagerBid)
).build()
// Runs once the candidates have loaded, ahead of the placement. Store the winner.
CloudX.arbiter(configuration, object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
nextWinner = result
}
})
// Runs at the placement. No arbiter call here.
fun showInterstitial(activity: Activity) {
when (nextWinner?.platform) {
CloudXArbiterPlatform.CLOUDX -> cloudXInterstitial.show(activity)
CloudXArbiterPlatform.ADMOB -> adMobInterstitial.show(activity)
CloudXArbiterPlatform.GAM -> adManagerInterstitial.show(activity)
else -> { } // no winner prepared; continue without an ad
}
nextWinner = null
}A winning Google bid reports its own platform — CloudXArbiterPlatform.ADMOB or CloudXArbiterPlatform.GAM. You no longer need to inspect result.platformName to tell these sources apart, as you would for a custom bid.
Pass a blank ad unit id and the bid still builds rather than crashing your app, but it carries no usable identity: it is never priced and the server rejects it.
Manual input with pre-bid ILRD
Some AdMob accounts expose impression-level revenue data pre-bid: the ad value for the loaded ad is available at load time, before the ad is shown. This is a legacy, account-gated capability, so check with your Google account team whether it is enabled for your account. An exact per-impression price that you know before show is the one case where supplying your own price beats CloudX’s estimate.
Pass the pre-bid ad value as manualRevenuePerImpressionUSD and it overrides the estimate:
val adMobBid = CloudXArbiterBid.adMob(
adUnitId = adMobAdUnitId,
networkName = adMobNetworkName ?: "admob",
// valueMicros is in micros: 1,000,000 micros is one currency unit.
// Do not also divide by 1,000 — this is already a per-impression value, not a CPM.
manualRevenuePerImpressionUSD = preBidAdValue.valueMicros / 1_000_000.0,
extras = emptyMap(),
)How the value is treated:
0.0is a real price. It means this bid is worth nothing — not that the price is missing.- Negative and non-finite values are not prices, so they are treated as absent and logged.
- A blank ad unit id drops the manual price entirely, because a bid with no identity cannot be validated.
manualRevenuePerImpressionUSD is revenue for a single impression, in USD — not CPM. Convert whatever your source reports:
- A Google ad value is reported in micros on Android, so divide by 1,000,000. A
valueMicrosof5000is0.005per impression. Do not confuse a micros value with an eCPM and divide by 1,000 as well. - A non-USD amount must be converted to USD first.
Step-by-step: arbitrate CloudX and LevelPlay
This walkthrough shows exactly which Unity LevelPlay callback to read and which values to pass into CloudX.arbiter(). It uses an interstitial, but the same field mapping applies to any format — see Supported ad formats for what changes for Banner and MREC.
Load both candidates
Create the CloudX and LevelPlay interstitials, attach listeners, and start a load on each platform.
val cloudXInterstitial = CloudX.createInterstitial(context, "YOUR_CLOUDX_AD_UNIT_ID")
cloudXInterstitial.listener = cloudXListener
cloudXInterstitial.load()
val levelPlayInterstitial = LevelPlayInterstitialAd("YOUR_LEVELPLAY_AD_UNIT_ID")
levelPlayInterstitial.setListener(levelPlayListener)
levelPlayInterstitial.loadAd()Capture each platform's loaded ad
LevelPlay delivers a LevelPlayAdInfo in its onAdLoaded callback; CloudX delivers a CloudXAd in onAdLoaded. Hold onto both — you read the arbiter inputs from them in the next step.
private var cloudXAd: CloudXAd? = null
private var levelPlayInfo: LevelPlayAdInfo? = null
// CloudXInterstitialListener
override fun onAdLoaded(cloudXAd: CloudXAd) {
this.cloudXAd = cloudXAd
}
// LevelPlayInterstitialAdListener
override fun onAdLoaded(levelPlayAdInfo: LevelPlayAdInfo) {
levelPlayInfo = levelPlayAdInfo
}Map the values into bids
Read the LevelPlay fields off LevelPlayAdInfo and pass them to CloudXArbiterBid.levelPlay(). The CloudX bid takes the CloudXAd directly. listOfNotNull submits only the platforms that actually loaded.
LevelPlayAdInfo field | Type | CloudXArbiterBid.levelPlay parameter |
|---|---|---|
adNetwork | String | networkName |
revenue | Double | revenue |
precision | String | precision |
val bids = listOfNotNull(
cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
levelPlayInfo?.let { info ->
CloudXArbiterBid.levelPlay(
networkName = info.adNetwork, // LevelPlayAdInfo.adNetwork
revenue = info.revenue, // LevelPlayAdInfo.revenue
precision = info.precision, // LevelPlayAdInfo.precision
)
},
)Run the arbiter and store the winner
Once both candidates have settled, wrap the bids in a CloudXArbiterConfiguration and pass it to CloudX.arbiter() with a listener. Store the result — do not show anything yet. onCompleted() runs on the main thread.
private var nextWinner: CloudXArbiterResult? = null
private fun prepareWinner() {
val configuration = CloudXArbiterConfiguration.builder(bids).build()
CloudX.arbiter(configuration, object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
nextWinner = result
}
})
}Show the stored winner at the placement
When the placement is reached, switch on the stored platform and show that platform’s ad. There is no arbiter call on this path. A stored CloudXArbiterPlatform.NONE, or no stored result at all, means there is no winner to show — continue without showing an ad.
fun showAtPlacement(activity: Activity) {
when (nextWinner?.platform) {
CloudXArbiterPlatform.CLOUDX -> cloudXInterstitial.show(activity)
CloudXArbiterPlatform.LEVELPLAY -> levelPlayInterstitial.showAd(activity)
else -> { } // no winner prepared; continue without an ad
}
nextWinner = null
}The ArbiterInterstitialController below packages these same steps into a reusable component.
Interstitial example
This controller is the reference implementation of the prepare-ahead rule for fullscreen formats. It arbitrates between two platforms — CloudX and Unity LevelPlay — and prepares a winner before the placement is reached:
- Load CloudX and LevelPlay in parallel.
- Wait until both platforms have loaded or failed.
- Submit only loaded candidates to Trusted Arbiter.
- Cache the selected platform.
- At the placement, show the cached winner immediately.
If both platforms fail, start another load cycle. If the placement is reached before a winner is prepared, continue the app flow without showing an ad.
/**
* Prepares a Trusted Arbiter winner ahead of time so an interstitial can be shown
* instantly when a placement is reached.
*
* Loads [cloudXInterstitial] and [levelPlayInterstitial] in parallel, waits until both
* have finished loading or failing, submits the loaded candidates to [CloudX.arbiter],
* and caches the selected [CloudXArbiterPlatform] in [nextWinner].
*/
class ArbiterInterstitialController(
private val cloudXInterstitial: CloudXInterstitialAd,
private val levelPlayInterstitial: LevelPlayInterstitialAd,
) {
/** Receives the arbitration outcome once a winner has been prepared. */
interface Listener {
/** Called when the arbiter has selected [platform] for the next show. */
fun onWinnerPrepared(platform: CloudXArbiterPlatform)
}
/** Set to observe [Listener.onWinnerPrepared] callbacks. */
var listener: Listener? = null
private var cloudXAd: CloudXAd? = null
private var cloudXLoadDone = false
private var levelPlayAdInfo: LevelPlayAdInfo? = null
private var levelPlayLoadDone = false
private var nextWinner: CloudXArbiterPlatform? = null
init {
cloudXInterstitial.listener = createCloudXListener()
levelPlayInterstitial.setListener(createLevelPlayListener())
}
/** Starts a load for each platform that does not currently hold a cached ad. */
fun loadMissingAds() {
if (cloudXAd == null) cloudXInterstitial.load()
if (levelPlayAdInfo == null) levelPlayInterstitial.loadAd()
}
/**
* Shows the prepared winner on [activity], returning true only when a show call was made.
*
* Returns false when no winner is ready or the cached ad is no longer available, in which
* case a fresh load cycle is started.
*/
fun showAtPlacement(activity: Activity): Boolean {
return when (nextWinner) {
CloudXArbiterPlatform.CLOUDX -> showCloudX(activity)
CloudXArbiterPlatform.LEVELPLAY -> showLevelPlay(activity)
else -> false
}
}
/**
* Runs the arbiter once both platforms have settled, then caches the winning platform.
*
* Returns early until both loads complete. If neither platform loaded, it restarts the
* load cycle; otherwise it submits the loaded candidates to [CloudX.arbiter].
*/
private fun maybePrepareWinner() {
if (!cloudXLoadDone || !levelPlayLoadDone) return
if (cloudXAd == null && levelPlayAdInfo == null) {
cloudXLoadDone = false
levelPlayLoadDone = false
loadMissingAds()
return
}
val bids = listOfNotNull(
cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
levelPlayAdInfo?.let { adInfo ->
CloudXArbiterBid.levelPlay(
networkName = adInfo.adNetwork,
revenue = adInfo.revenue,
precision = adInfo.precision,
)
}
)
CloudX.arbiter(
configuration = CloudXArbiterConfiguration.builder(bids).build(),
listener = object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
nextWinner = result.platform
listener?.onWinnerPrepared(result.platform)
}
}
)
}
private fun showCloudX(activity: Activity): Boolean {
if (cloudXInterstitial.isAdReady) {
cloudXInterstitial.show(activity)
return true
}
clearCloudXAndLoadMissingAds()
return false
}
private fun showLevelPlay(activity: Activity): Boolean {
if (levelPlayInterstitial.isAdReady) {
levelPlayInterstitial.showAd(activity)
return true
}
clearLevelPlayAndLoadMissingAds()
return false
}
private fun clearCloudXAndLoadMissingAds() {
cloudXAd = null
cloudXLoadDone = false
nextWinner = null
loadMissingAds()
}
private fun clearLevelPlayAndLoadMissingAds() {
levelPlayAdInfo = null
levelPlayLoadDone = false
nextWinner = null
loadMissingAds()
}
private fun createCloudXListener() = object : CloudXInterstitialListener {
override fun onAdLoaded(cloudXAd: CloudXAd) {
this@ArbiterInterstitialController.cloudXAd = cloudXAd
cloudXLoadDone = true
maybePrepareWinner()
}
override fun onAdLoadFailed(adUnitId: String, cloudXError: CloudXError) {
cloudXAd = null
cloudXLoadDone = true
maybePrepareWinner()
}
override fun onAdDisplayed(cloudXAd: CloudXAd) = Unit
override fun onAdDisplayFailed(cloudXAd: CloudXAd, cloudXError: CloudXError) {
clearCloudXAndLoadMissingAds()
}
override fun onAdHidden(cloudXAd: CloudXAd) {
clearCloudXAndLoadMissingAds()
}
override fun onAdClicked(cloudXAd: CloudXAd) = Unit
}
private fun createLevelPlayListener() = object : LevelPlayInterstitialAdListener {
override fun onAdLoaded(levelPlayAdInfo: LevelPlayAdInfo) {
this@ArbiterInterstitialController.levelPlayAdInfo = levelPlayAdInfo
levelPlayLoadDone = true
maybePrepareWinner()
}
override fun onAdLoadFailed(levelPlayAdError: LevelPlayAdError) {
levelPlayAdInfo = null
levelPlayLoadDone = true
maybePrepareWinner()
}
override fun onAdInfoChanged(levelPlayAdInfo: LevelPlayAdInfo) {
this@ArbiterInterstitialController.levelPlayAdInfo = levelPlayAdInfo
}
override fun onAdDisplayed(levelPlayAdInfo: LevelPlayAdInfo) = Unit
override fun onAdDisplayFailed(
levelPlayAdError: LevelPlayAdError,
levelPlayAdInfo: LevelPlayAdInfo
) {
clearLevelPlayAndLoadMissingAds()
}
override fun onAdClosed(levelPlayAdInfo: LevelPlayAdInfo) {
clearLevelPlayAndLoadMissingAds()
}
override fun onAdClicked(levelPlayAdInfo: LevelPlayAdInfo) = Unit
}
}showAtPlacement() returns true only when an ad show call was made. onAdInfoChanged() keeps the cached LevelPlay candidate up to date while it remains loaded.
For PubMatic OpenWrap, create a third-party bid with CloudXArbiterBid.pubmatic(price, partnerName). If the arbiter service is unavailable, the SDK falls back to the highest comparable USD bid among the supplied supported bid inputs.
App Open example
App Open is a fullscreen format, so it prepares a winner ahead of the placement exactly like interstitial and rewarded. What differs is the trigger. Nothing in your UI asks for an App Open ad, so the decision has to be made while the app is in the background — a user returning to the app must never wait on a load or an arbiter round trip.
ArbiterAppOpenController arbitrates a CloudX App Open ad against an AdMob App Open ad:
- Load both platforms at startup, and again after every show.
- Run the arbiter once both have settled, and store the winner.
- On the next foreground transition, show the stored winner and start the next cycle.
- If no winner is stored, return the user to the app without an ad and start a fresh load.
/**
* Arbitrates a CloudX App Open ad against an AdMob App Open ad and shows the winner on the
* next foreground transition.
*
* App Open has no user-initiated show, so the winner must already be chosen by the time the
* user comes back. [loadMissingAds] starts both loads, the arbiter runs as soon as both
* platforms settle, and [showOnForeground] only shows a result that was stored earlier.
*/
class ArbiterAppOpenController(
private val context: Context,
cloudXAdUnitId: String,
private val adMobAdUnitId: String,
) {
/** Receives the arbitration outcome once a winner has been prepared. */
interface Listener {
/** Called when the arbiter has selected [platform] for the next foreground show. */
fun onWinnerPrepared(platform: CloudXArbiterPlatform)
}
/** Set to observe [Listener.onWinnerPrepared] callbacks. */
var listener: Listener? = null
private val cloudXAppOpen: CloudXAppOpenAd = CloudX.createAppOpen(context, cloudXAdUnitId)
private var cloudXAd: CloudXAd? = null
private var cloudXLoadDone = false
private var adMobAppOpen: AppOpenAd? = null
/** The ad that is presenting, or presented last. Held so a late paid event still has an ad. */
private var presentedAdMobAd: AppOpenAd? = null
private var adMobLoadDone = false
private var cloudXLoadInFlight = false
private var adMobLoadInFlight = false
private var nextWinner: CloudXArbiterPlatform? = null
private var isShowingAd = false
init {
cloudXAppOpen.listener = createCloudXListener()
}
/**
* Starts a load for each platform that holds neither a cached ad nor a request in flight.
*
* Each load it starts clears that platform's `LoadDone` flag, so a round only arbitrates once
* every request it started has come back. Without that reset, a retry for the platform that
* failed last round lands against a flag still set from the previous round, and the arbiter
* runs on a one-candidate snapshot.
*/
fun loadMissingAds() {
if (cloudXAd == null && !cloudXLoadInFlight) {
cloudXLoadInFlight = true
cloudXLoadDone = false
cloudXAppOpen.load()
}
if (adMobAppOpen == null && !adMobLoadInFlight) {
adMobLoadInFlight = true
adMobLoadDone = false
loadAdMob()
}
}
/**
* Shows the prepared winner on [activity], returning true only when a show call was made.
*
* Call this from a foreground observer. It never holds up the return to the app: with an ad
* already on screen, or no winner prepared, it returns false and starts the next load cycle.
*/
fun showOnForeground(activity: Activity): Boolean {
if (isShowingAd) return false
return when (nextWinner) {
CloudXArbiterPlatform.CLOUDX -> showCloudX(activity)
CloudXArbiterPlatform.ADMOB -> showAdMob(activity)
CloudXArbiterPlatform.NONE -> {
// The arbiter settled on no winner, so both cached ads are unusable. Drop them:
// loadMissingAds() only requests a platform whose slot is empty, so without this
// the same two candidates would sit there and no later round could ever run.
discardCandidatesAndReload()
false
}
// nextWinner is null — a cycle is still in flight. Leave it alone.
else -> false
}
}
/** Releases both platforms' ads. Call this from the owning component's teardown. */
fun destroy() {
cloudXAppOpen.destroy()
adMobAppOpen = null
presentedAdMobAd = null
}
/**
* Runs the arbiter once both platforms have settled, then caches the winning platform.
*
* Returns early until both loads complete. If neither platform loaded, it restarts the
* load cycle; otherwise it submits the loaded candidates to [CloudX.arbiter].
*/
private fun maybePrepareWinner() {
if (!cloudXLoadDone || !adMobLoadDone) return
if (cloudXAd == null && adMobAppOpen == null) {
loadMissingAds()
return
}
val bids = listOfNotNull(
cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
adMobAppOpen?.let { CloudXArbiterBid.adMob(adUnitId = adMobAdUnitId) },
)
CloudX.arbiter(
configuration = CloudXArbiterConfiguration.builder(bids).build(),
listener = object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
nextWinner = result.platform
listener?.onWinnerPrepared(result.platform)
}
}
)
}
private fun showCloudX(activity: Activity): Boolean {
if (cloudXAppOpen.isAdReady) {
isShowingAd = true
cloudXAppOpen.show(activity, "app_foreground")
return true
}
clearCloudXAndLoadMissingAds()
return false
}
private fun showAdMob(activity: Activity): Boolean {
val ad = adMobAppOpen
if (ad == null) {
clearAdMobAndLoadMissingAds()
return false
}
ad.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
clearAdMobAndLoadMissingAds()
}
override fun onAdFailedToShowFullScreenContent(adError: AdError) {
Log.e("CloudX", "AdMob App Open failed to show: ${adError.message}")
clearAdMobAndLoadMissingAds()
}
}
// An AppOpenAd is single-use: show() consumes it. Move it aside now, so a load that
// lands mid-show cannot arbitrate a spent ad. It stays alive in presentedAdMobAd
// because AdMob can deliver the paid event after the ad has already been dismissed,
// and that revenue is what prices the next AdMob bid.
presentedAdMobAd = ad
adMobAppOpen = null
isShowingAd = true
ad.show(activity)
return true
}
private fun loadAdMob() {
AppOpenAd.load(
context,
adMobAdUnitId,
AdRequest.Builder().build(),
object : AppOpenAd.AppOpenAdLoadCallback() {
override fun onAdLoaded(ad: AppOpenAd) {
ad.setOnPaidEventListener { adValue -> reportAdMobPaidEvent(ad, adValue) }
adMobAppOpen = ad
adMobLoadInFlight = false
adMobLoadDone = true
maybePrepareWinner()
}
override fun onAdFailedToLoad(error: LoadAdError) {
Log.w("CloudX", "AdMob App Open failed to load: ${error.message}")
adMobAppOpen = null
adMobLoadInFlight = false
adMobLoadDone = true
maybePrepareWinner()
}
},
)
}
/**
* Forwards AdMob's realized revenue to CloudX. This is a required part of the integration,
* not optional analytics: CloudX prices future AdMob bids from what you report back.
*
* toCloudXRevenuePrecision() is the mapping shown in
* "Report Google paid events back to CloudX" above.
*/
private fun reportAdMobPaidEvent(ad: AppOpenAd, adValue: AdValue) {
val servedBy = ad.responseInfo.loadedAdapterResponseInfo
val accepted = CloudX.reportRevenueData(
CloudXRevenueData.builder(
platform = CloudXRevenuePlatform.ADMOB,
revenue = adValue.valueMicros / 1_000_000.0,
adFormat = "app_open",
)
.currencyCode(adValue.currencyCode)
.precision(adValue.precisionType.toCloudXRevenuePrecision())
.networkName(servedBy?.adSourceName)
.adUnitId(adMobAdUnitId)
.thirdPartyAdPlacementId(servedBy?.adSourceInstanceName)
.build(),
)
if (!accepted) {
Log.w("CloudX", "AdMob App Open paid event was not accepted by the CloudX SDK")
}
}
/** Drops both candidates and starts a new cycle. Used when the arbiter reports no winner. */
private fun discardCandidatesAndReload() {
cloudXAd = null
adMobAppOpen = null
cloudXLoadDone = false
adMobLoadDone = false
nextWinner = null
loadMissingAds()
}
private fun clearCloudXAndLoadMissingAds() {
cloudXAd = null
cloudXLoadDone = false
nextWinner = null
isShowingAd = false
loadMissingAds()
}
private fun clearAdMobAndLoadMissingAds() {
adMobAppOpen = null
adMobLoadDone = false
nextWinner = null
isShowingAd = false
loadMissingAds()
}
private fun createCloudXListener() = object : CloudXAppOpenListener {
override fun onAdLoaded(cloudXAd: CloudXAd) {
this@ArbiterAppOpenController.cloudXAd = cloudXAd
cloudXLoadInFlight = false
cloudXLoadDone = true
maybePrepareWinner()
}
override fun onAdLoadFailed(adUnitId: String, cloudXError: CloudXError) {
Log.w("CloudX", "CloudX App Open failed to load: ${cloudXError.message}")
cloudXAd = null
cloudXLoadInFlight = false
cloudXLoadDone = true
maybePrepareWinner()
}
override fun onAdDisplayed(cloudXAd: CloudXAd) = Unit
override fun onAdDisplayFailed(cloudXAd: CloudXAd, cloudXError: CloudXError) {
Log.e("CloudX", "CloudX App Open failed to display: ${cloudXError.message}")
clearCloudXAndLoadMissingAds()
}
override fun onAdHidden(cloudXAd: CloudXAd) {
clearCloudXAndLoadMissingAds()
}
override fun onAdClicked(cloudXAd: CloudXAd) = Unit
}
}showOnForeground() returns true only when a show call was made. isShowingAd keeps the ad’s own fullscreen presentation from being read as a second foreground transition — without it, a lifecycle observer can re-enter showOnForeground() while an ad is already on screen.
Drive it from the process lifecycle, so the show happens on a real return to the foreground rather than on every activity resume:
// In the class that owns the controller, for example your Application subclass:
private val controller = ArbiterAppOpenController(context, cloudXAdUnitId, adMobAdUnitId)
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
currentActivity?.let { controller.showOnForeground(it) }
}
})Both platforms report a winning App Open bid the same way as any other format: CloudXArbiterPlatform.CLOUDX or CloudXArbiterPlatform.ADMOB. Keep the App Open placement rules from App Open Ads — arbitration changes which network fills the placement, not when it is appropriate to show one.
Banner and MREC arbitration
Banner and MREC are view formats: multiple networks can hold a loaded ad at the same time, but only one view can be on screen. Arbitrating between them adds three requirements that fullscreen formats don’t have.
Disable auto-refresh
Auto-refresh must be turned off everywhere so the arbiter — not each network’s internal timer — controls when a new ad is shown:
- Turn off auto-refresh for the ad unit in the CloudX dashboard.
- Call
stopAutoRefresh()on theCloudXAdViewimmediately after creating it. - Disable auto-refresh on the other arbitrated networks’ banner APIs as well.
View attachment
Load every candidate off-screen. Only the winning bid’s banner view may be attached to the view hierarchy or displayed. Keep losing views unattached — do not add them to a parent view, even temporarily — since attaching a view renders it and can fire an impression for a bid the arbiter rejected. Unlike the standard banner integration, which adds the view to the layout at creation time (see Banner & MREC), with Trusted Arbiter the view must not be attached at creation — attach it only after arbitration selects it as the winner.
Refresh cycle
With auto-refresh disabled, the arbiter must drive its own refresh loop:
Run the first round
Load candidates from every network in parallel, run the arbiter, and attach the winning view.
Start the next load on the winner
As soon as the winner’s impression fires, immediately start loading a new fill from the winning network so it’s ready for the next round.
Retain non-winning fills
Keep the non-winning networks’ already-filled ads for the next arbitration round. Re-request loads only from networks that did not fill in the previous round.
Re-arbitrate
Once the outstanding load responses return, run the arbiter again over the full candidate set.
Swap on an interval
Refresh the displayed ad on a 20–30 second interval by attaching the new winner’s view in place of the old one. Intervals shorter than 20 seconds decrease CPM performance.
Banner example
This controller is the reference implementation of the arbitrate-then-render rule for view formats. It arbitrates a CloudX banner against a LevelPlay banner and drives the refresh cycle above. The same structure applies to MREC — swap CloudX.createBanner() for CloudX.createMREC() and the LevelPlay banner type for the LevelPlay MREC type.
/**
* Drives Trusted Arbiter for a banner placement: loads [cloudXBanner] and
* [levelPlayBanner] in parallel, arbitrates between whichever candidates filled,
* attaches the winning view to [container], and refreshes on [refreshIntervalMs].
*
* Auto-refresh must be disabled on both networks (dashboard setting for CloudX,
* `stopAutoRefresh()` here, and the equivalent LevelPlay setting) — this controller
* owns the refresh cycle instead.
*/
class ArbiterBannerController(
private val cloudXBanner: CloudXAdView,
private val levelPlayBanner: LevelPlayBannerAdView,
private val container: ViewGroup,
private val refreshIntervalMs: Long = 25_000L,
) {
private val handler = Handler(Looper.getMainLooper())
private var cloudXAd: CloudXAd? = null
private var cloudXFilled = false
private var cloudXLoadDone = false
private var levelPlayAdInfo: LevelPlayAdInfo? = null
private var levelPlayFilled = false
private var levelPlayLoadDone = false
private var currentWinner: CloudXArbiterPlatform? = null
private val refreshRunnable = Runnable { runArbitrationRound() }
init {
cloudXBanner.stopAutoRefresh()
cloudXBanner.listener = createCloudXListener()
cloudXBanner.revenueListener = createCloudXRevenueListener()
levelPlayBanner.bannerListener = createLevelPlayListener()
}
/** Starts the first round: loads every network that isn't already holding a fill. */
fun start() {
loadMissingAds()
}
fun stop() {
handler.removeCallbacks(refreshRunnable)
}
private fun loadMissingAds() {
if (!cloudXFilled) cloudXBanner.load()
if (!levelPlayFilled) levelPlayBanner.loadAd()
}
/** Runs once both networks have settled (filled or failed); re-arbitrates only pending candidates. */
private fun maybeArbitrate() {
if (!cloudXLoadDone || !levelPlayLoadDone) return
val bids = listOfNotNull(
cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
levelPlayAdInfo?.let { info ->
CloudXArbiterBid.levelPlay(
networkName = info.adNetwork,
revenue = info.revenue,
precision = info.precision,
)
},
)
if (bids.isEmpty()) {
loadMissingAds()
return
}
CloudX.arbiter(
configuration = CloudXArbiterConfiguration.builder(bids).build(),
listener = object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
showWinner(result.platform)
}
}
)
}
private fun runArbitrationRound() {
maybeArbitrate()
}
/** Attaches only the winning view; the losing view is never added to [container]. */
private fun showWinner(platform: CloudXArbiterPlatform) {
currentWinner = platform
container.removeAllViews()
when (platform) {
CloudXArbiterPlatform.CLOUDX -> container.addView(cloudXBanner)
CloudXArbiterPlatform.LEVELPLAY -> container.addView(levelPlayBanner)
else -> return // CloudXArbiterPlatform.NONE — no winner; leave container empty
}
handler.removeCallbacks(refreshRunnable)
handler.postDelayed(refreshRunnable, refreshIntervalMs)
}
/** Starts the winning network's next load immediately after its impression fires. */
private fun onWinnerImpression(platform: CloudXArbiterPlatform) {
when (platform) {
CloudXArbiterPlatform.CLOUDX -> {
cloudXFilled = false
cloudXLoadDone = false
cloudXBanner.load()
}
CloudXArbiterPlatform.LEVELPLAY -> {
levelPlayFilled = false
levelPlayLoadDone = false
levelPlayBanner.loadAd()
}
else -> Unit
}
}
private fun createCloudXListener() = object : CloudXAdViewListener {
override fun onAdLoaded(cloudXAd: CloudXAd) {
this@ArbiterBannerController.cloudXAd = cloudXAd
cloudXFilled = true
cloudXLoadDone = true
maybeArbitrate()
}
override fun onAdLoadFailed(adUnitId: String, cloudXError: CloudXError) {
cloudXAd = null
cloudXFilled = false
cloudXLoadDone = true
maybeArbitrate()
}
override fun onAdClicked(cloudXAd: CloudXAd) = Unit
override fun onAdExpanded(cloudXAd: CloudXAd) = Unit
override fun onAdCollapsed(cloudXAd: CloudXAd) = Unit
}
private fun createCloudXRevenueListener() = object : CloudXAdRevenueListener {
override fun onAdRevenuePaid(cloudXAd: CloudXAd) {
// onAdRevenuePaid fires at CloudX impression time; only act on it while CloudX is showing.
if (currentWinner == CloudXArbiterPlatform.CLOUDX) {
onWinnerImpression(CloudXArbiterPlatform.CLOUDX)
}
}
}
private fun createLevelPlayListener() = object : LevelPlayBannerAdViewListener {
override fun onAdLoaded(levelPlayAdInfo: LevelPlayAdInfo) {
this@ArbiterBannerController.levelPlayAdInfo = levelPlayAdInfo
levelPlayFilled = true
levelPlayLoadDone = true
maybeArbitrate()
}
override fun onAdLoadFailed(levelPlayAdError: LevelPlayAdError) {
levelPlayAdInfo = null
levelPlayFilled = false
levelPlayLoadDone = true
maybeArbitrate()
}
override fun onAdDisplayed(levelPlayAdInfo: LevelPlayAdInfo) {
if (currentWinner == CloudXArbiterPlatform.LEVELPLAY) {
onWinnerImpression(CloudXArbiterPlatform.LEVELPLAY)
}
}
override fun onAdClicked(levelPlayAdInfo: LevelPlayAdInfo) = Unit
}
}onAdRevenuePaid() (CloudX) and onAdDisplayed() (LevelPlay) are where each network’s impression fires; that is where the winning network’s next load is kicked off, keeping a fresh fill ready for the following round without holding up the current display. Networks that already have a filled, unattached ad skip straight past loadMissingAds() until their fill is used or expires.
Native example
Native is a view format, so it arbitrates and then renders — but unlike banner and MREC, a native ad has no view until you make one. That is what makes native the easiest format to get wrong: loadAd(adView) renders the ad at load time, before the arbiter has chosen anything.
ArbiterNativeController arbitrates a CloudX native ad against an AdMob native ad and renders exactly one of them into the container:
- Load both platforms with no view attached.
- Run the arbiter once both have settled.
- Build a view for the winner only, render into it, and attach it.
- Keep the losing platform’s ad loaded and unrendered for the next round.
/**
* Arbitrates a CloudX native ad against an AdMob native ad and renders only the winner
* into [container].
*
* Both platforms load without a view: [CloudXNativeAdLoader.loadAd] is called with no ad view
* so nothing is rendered at load time, and the AdMob ad is held unrendered. The losing bid's
* assets are never rendered or attached, so they never fire an impression.
*/
class ArbiterNativeController(
private val context: Context,
private val container: ViewGroup,
private val binder: CloudXNativeAdViewBinder,
cloudXAdUnitId: String,
private val adMobAdUnitId: String,
) {
private val cloudXLoader: CloudXNativeAdLoader =
CloudX.createNativeAdLoader(context, cloudXAdUnitId)
private var cloudXAd: CloudXAd? = null
private var cloudXLoadDone = false
private var adMobNativeAd: NativeAd? = null
private var adMobLoadDone = false
private var cloudXLoadInFlight = false
private var adMobLoadInFlight = false
private var renderedPlatform: CloudXArbiterPlatform? = null
private var renderedAdView: View? = null
// The ads behind the attached view. They outlive their own candidate state: a rendered ad
// stays on screen after its impression, until its replacement is attached.
private var renderedCloudXAd: CloudXAd? = null
private var renderedAdMobAd: NativeAd? = null
init {
cloudXLoader.nativeAdListener = createCloudXListener()
cloudXLoader.revenueListener = createCloudXRevenueListener()
}
/** Starts the first round: loads every platform that is not already holding a fill. */
fun start() {
loadMissingAds()
}
/** Releases both platforms' ads and detaches the rendered view. */
fun destroy() {
// detachRenderedAd() releases the rendered AdMob ad; only destroy the candidate here
// when it is a different, never-rendered instance.
val unrenderedAdMobAd = adMobNativeAd.takeIf { it !== renderedAdMobAd }
detachRenderedAd()
cloudXLoader.destroy()
unrenderedAdMobAd?.destroy()
adMobNativeAd = null
}
/**
* Starts a load for each platform that holds neither a filled ad nor a request in flight.
*
* Each load it starts clears that platform's `LoadDone` flag, so a round only arbitrates once
* every request it started has come back.
*/
private fun loadMissingAds() {
// loadAd() with no ad view defers rendering. Never use loadAd(adView) here: that
// renders the CloudX ad at load time, before the arbiter has chosen a winner.
if (cloudXAd == null && !cloudXLoadInFlight) {
cloudXLoadInFlight = true
cloudXLoadDone = false
cloudXLoader.loadAd()
}
if (adMobNativeAd == null && !adMobLoadInFlight) {
adMobLoadInFlight = true
adMobLoadDone = false
loadAdMob()
}
}
/** Runs once both platforms have settled (filled or failed), then renders the winner. */
private fun maybeArbitrate() {
if (!cloudXLoadDone || !adMobLoadDone) return
val bids = listOfNotNull(
cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
adMobNativeAd?.let { CloudXArbiterBid.adMob(adUnitId = adMobAdUnitId) },
)
if (bids.isEmpty()) {
loadMissingAds()
return
}
CloudX.arbiter(
configuration = CloudXArbiterConfiguration.builder(bids).build(),
listener = object : CloudXArbiterListener {
override fun onCompleted(result: CloudXArbiterResult) {
renderWinner(result.platform)
}
}
)
}
/** Renders only the winning platform's assets; the loser stays unrendered and unattached. */
private fun renderWinner(platform: CloudXArbiterPlatform) {
// Rendering is what fires the impression, so never render the same ad twice.
// maybeArbitrate() runs again whenever the losing candidate reloads, and the same
// platform winning that round is a normal outcome — but by then the served ad has been
// dropped as a candidate, so a repeat win is always a new ad and does render.
if (isAlreadyRendered(platform)) return
if (platform == CloudXArbiterPlatform.NONE) {
// No winner. Keep whatever is already on screen — detaching it here would drop a
// perfectly good ad — and drop only the candidates that lost, so the next round has
// something new to compare.
discardUnrenderedCandidatesAndReload()
return
}
detachRenderedAd()
when (platform) {
CloudXArbiterPlatform.CLOUDX -> renderCloudX()
CloudXArbiterPlatform.ADMOB -> renderAdMob()
else -> Unit
}
}
/** True when this platform's current candidate is the ad already attached to the container. */
private fun isAlreadyRendered(platform: CloudXArbiterPlatform): Boolean = when (platform) {
CloudXArbiterPlatform.CLOUDX -> cloudXAd != null && cloudXAd === renderedCloudXAd
CloudXArbiterPlatform.ADMOB -> adMobNativeAd != null && adMobNativeAd === renderedAdMobAd
else -> false
}
/** Drops every candidate that is not currently rendered, then starts a new cycle. */
private fun discardUnrenderedCandidatesAndReload() {
if (renderedPlatform != CloudXArbiterPlatform.CLOUDX) {
cloudXAd = null
cloudXLoadDone = false
}
if (renderedPlatform != CloudXArbiterPlatform.ADMOB) {
adMobNativeAd = null
adMobLoadDone = false
}
loadMissingAds()
}
/**
* Removes only the ad view this controller attached, so anything else the publisher keeps in
* the container — a placeholder, a spinner, a label — survives the round.
*
* This is also the only place a rendered ad is released. It stays alive for as long as it is
* on screen, and goes away when its replacement is attached or the controller is destroyed.
*/
private fun detachRenderedAd() {
renderedAdView?.let { container.removeView(it) }
renderedAdView = null
renderedPlatform = null
renderedCloudXAd?.let { cloudXLoader.destroy(it) }
renderedCloudXAd = null
renderedAdMobAd?.destroy()
renderedAdMobAd = null
}
private fun renderCloudX() {
val ad = cloudXAd ?: return
val adView = CloudXNativeAdView(context, binder)
cloudXLoader.render(adView, ad)
container.addView(adView)
renderedAdView = adView
renderedPlatform = CloudXArbiterPlatform.CLOUDX
renderedCloudXAd = ad
}
private fun renderAdMob() {
val ad = adMobNativeAd ?: return
// Inflate your own NativeAdView layout and assign its asset views, exactly as you
// would in a non-arbitrated AdMob native integration.
val adView = LayoutInflater.from(context)
.inflate(R.layout.admob_native_ad_layout, container, false) as NativeAdView
adView.setNativeAd(ad)
container.addView(adView)
renderedAdView = adView
renderedPlatform = CloudXArbiterPlatform.ADMOB
renderedAdMobAd = ad
}
private fun loadAdMob() {
AdLoader.Builder(context, adMobAdUnitId)
.forNativeAd { nativeAd ->
nativeAd.setOnPaidEventListener { adValue ->
reportAdMobPaidEvent(nativeAd, adValue)
}
adMobNativeAd = nativeAd
adMobLoadInFlight = false
adMobLoadDone = true
maybeArbitrate()
}
.withAdListener(object : AdListener() {
override fun onAdFailedToLoad(error: LoadAdError) {
Log.w("CloudX", "AdMob native ad failed to load: ${error.message}")
adMobNativeAd = null
adMobLoadInFlight = false
adMobLoadDone = true
maybeArbitrate()
}
// The winning AdMob ad is consumed the moment its impression fires, so request
// the next one here. Without this the controller keeps bidding a spent ad for
// the rest of the session.
override fun onAdImpression() {
if (renderedPlatform == CloudXArbiterPlatform.ADMOB) {
onWinnerImpression(CloudXArbiterPlatform.ADMOB)
}
}
})
.build()
.loadAd(AdRequest.Builder().build())
}
/**
* Forwards AdMob's realized revenue to CloudX. This is a required part of the integration,
* not optional analytics: CloudX prices future AdMob bids from what you report back.
*
* toCloudXRevenuePrecision() is the mapping shown in
* "Report Google paid events back to CloudX" above.
*/
private fun reportAdMobPaidEvent(ad: NativeAd, adValue: AdValue) {
val servedBy = ad.responseInfo?.loadedAdapterResponseInfo
val accepted = CloudX.reportRevenueData(
CloudXRevenueData.builder(
platform = CloudXRevenuePlatform.ADMOB,
revenue = adValue.valueMicros / 1_000_000.0,
adFormat = "native",
)
.currencyCode(adValue.currencyCode)
.precision(adValue.precisionType.toCloudXRevenuePrecision())
.networkName(servedBy?.adSourceName)
.adUnitId(adMobAdUnitId)
.thirdPartyAdPlacementId(servedBy?.adSourceInstanceName)
.build(),
)
if (!accepted) {
Log.w("CloudX", "AdMob native paid event was not accepted by the CloudX SDK")
}
}
private fun createCloudXListener() = object : CloudXNativeAdListener {
// adView is null here because loadAd() was called without one. The ad is rendered
// later, and only if it wins.
override fun onNativeAdLoaded(adView: CloudXNativeAdView?, ad: CloudXAd) {
cloudXAd = ad
cloudXLoadInFlight = false
cloudXLoadDone = true
maybeArbitrate()
}
override fun onNativeAdLoadFailed(adUnitId: String, error: CloudXError) {
Log.w("CloudX", "CloudX native ad failed to load: ${error.message}")
cloudXAd = null
cloudXLoadInFlight = false
cloudXLoadDone = true
maybeArbitrate()
}
override fun onNativeAdClicked(ad: CloudXAd) = Unit
override fun onNativeAdExpired(ad: CloudXAd) {
// detachRenderedAd() releases the rendered ad, so hand it this one instead of
// destroying it twice.
if (renderedCloudXAd !== ad) cloudXLoader.destroy(ad)
clearCloudXAndReload()
}
override fun onNativeAdClosed(ad: CloudXAd) {
if (renderedCloudXAd !== ad) cloudXLoader.destroy(ad)
clearCloudXAndReload()
}
}
private fun createCloudXRevenueListener() = object : CloudXAdRevenueListener {
override fun onAdRevenuePaid(cloudXAd: CloudXAd) {
// onAdRevenuePaid fires at CloudX impression time; only act on it while CloudX is
// the platform on screen.
if (renderedPlatform == CloudXArbiterPlatform.CLOUDX) {
onWinnerImpression(CloudXArbiterPlatform.CLOUDX)
}
}
}
/**
* Starts the next cycle once the winner's impression has fired.
*
* The rendered view stays on screen and the ad behind it stays alive. A native slot has
* nothing to fall back to, so detaching the ad the moment it is billed would leave the slot
* blank for as long as the next round takes. Only the candidate state is dropped, so the
* spent ad is never bid or rendered again; the ad itself is released in detachRenderedAd(),
* when its replacement goes up. This is the same rule the banner controller follows.
*/
private fun onWinnerImpression(platform: CloudXArbiterPlatform) {
when (platform) {
CloudXArbiterPlatform.CLOUDX -> {
cloudXAd = null
cloudXLoadDone = false
cloudXLoadInFlight = false
}
CloudXArbiterPlatform.ADMOB -> {
adMobNativeAd = null
adMobLoadDone = false
adMobLoadInFlight = false
}
else -> return
}
loadMissingAds()
}
/** Drops the CloudX candidate, detaching it first if it is the one on screen. */
private fun clearCloudXAndReload() {
if (renderedPlatform == CloudXArbiterPlatform.CLOUDX) {
detachRenderedAd()
}
cloudXAd = null
cloudXLoadDone = false
cloudXLoadInFlight = false
loadMissingAds()
}
}onNativeAdLoaded() delivers a null adView because the load was started without one — that null is the signal that rendering is still yours to trigger. Build the CloudXNativeAdView in renderCloudX() and pass it to render() only after the arbiter has named CloudX the winner.
The losing platform keeps its filled ad for the next round, so only the platform that was consumed or expired is re-requested. A rendered ad is spent, so each platform drops it as a candidate as soon as the impression fires — onAdRevenuePaid for CloudX, onAdImpression() for AdMob — and requests a new one.
Neither handler detaches the view. A native slot has nothing to fall back to, so removing the served ad at its impression would leave the slot blank for as long as the next round takes. The rendered ad stays up and is released in detachRenderedAd(), at the moment its replacement is attached or the controller is destroyed — the same rule the banner controller follows. An ad that expires or is closed while it is on screen is the one case that does detach early, which is why renderedPlatform is tracked.
renderWinner() returns early when the arbiter picks the platform already on screen, because rendering is what fires the impression and that round did not change the winner.
To refresh a native slot on a timer, use the same cycle as Banner and MREC arbitration: re-arbitrate on a 20–30 second interval and swap the rendered view for the new winner’s. For a Reels-style feed, create one controller per slot — see Native Ads.
Custom Bid Inputs
Use CloudXArbiterBid.custom() when you want Trusted Arbiter to compare CloudX with a third-party platform that does not have a dedicated bid helper.
val customBid = CloudXArbiterBid.custom(
platformName = "my_mediation_platform",
networkName = "winning_demand_source",
revenuePerImpressionUSD = 0.00125,
precision = CloudXArbiterPrecision.EXACT,
extras = mapOf("ad_unit" to "third-party-ad-unit-id"),
)
val configuration = CloudXArbiterConfiguration.builder(
listOf(CloudXArbiterBid.cloudX(cloudXAd), customBid)
).build()When a custom bid wins, result.platform is CloudXArbiterPlatform.CUSTOM and result.platformName contains the platformName supplied on the bid. Pass revenuePerImpressionUSD as revenue for one impression in USD, not CPM. Use CloudXArbiterPrecision.EXACT, ESTIMATED, PUBLISHER_DEFINED, or UNDEFINED to describe that revenue value.