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.

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

  1. Load all candidates in parallel.
  2. When every candidate has settled — loaded or failed — run the arbiter and store the result.
  3. At the placement, show the stored winner immediately. No arbiter call here.
  4. 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.0 is 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 valueMicros of 5000 is 0.005 per 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 fieldTypeCloudXArbiterBid.levelPlay parameter
adNetworkStringnetworkName
revenueDoublerevenue
precisionStringprecision
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:

  1. Load CloudX and LevelPlay in parallel.
  2. Wait until both platforms have loaded or failed.
  3. Submit only loaded candidates to Trusted Arbiter.
  4. Cache the selected platform.
  5. 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.

ArbiterInterstitialController.kt
/**
 * 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:

  1. Load both platforms at startup, and again after every show.
  2. Run the arbiter once both have settled, and store the winner.
  3. On the next foreground transition, show the stored winner and start the next cycle.
  4. If no winner is stored, return the user to the app without an ad and start a fresh load.
ArbiterAppOpenController.kt
/**
 * 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 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 the CloudXAdView immediately 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.

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.

ArbiterBannerController.kt
/**
 * 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:

  1. Load both platforms with no view attached.
  2. Run the arbiter once both have settled.
  3. Build a view for the winner only, render into it, and attach it.
  4. Keep the losing platform’s ad loaded and unrendered for the next round.
ArbiterNativeController.kt
/**
 * 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.