Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,547 changes: 1,547 additions & 0 deletions Assets/Scenes/ArbiterScene.unity

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Assets/Scenes/ArbiterScene.unity.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Assets/Scripts/Arbiter.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

217 changes: 217 additions & 0 deletions Assets/Scripts/Arbiter/ArbiterAdController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using CloudX;
using GoogleMobileAds.Api;
using UnityEngine;

/*
* Shared base for the Arbiter/TPA controllers. Every format follows the same
* Trusted Arbiter rule - CloudX and AdMob load in parallel, the loaded ones
* become bids, CloudXSdk.Arbiter picks the platform - so the bookkeeping the
* formats share lives here once: ids, the loaded CloudXAd (the CloudX bid is
* built from it), the arbiter call with its in-flight guard, the AdMob bid, and
* the Google paid-event forwarding that prices AdMob bids. The two families
* split below: fullscreen (interstitial, rewarded) prepares a winner ahead of
* the placement in ArbiterFullscreenController; inline (banner, MREC)
* arbitrates and then renders on a refresh cycle in ArbiterInlineController.
*
* Platform identity uses the SDK's CloudXArbiterPlatform everywhere (CloudX,
* AdMob) so the same value names a loaded side, a bid, and a winner.
*
* A publisher copying this into an app takes this base plus the one family base
* and the one concrete they need.
*/
public abstract class ArbiterAdController : IDisposable
{
private const string TAG = "CloudXUnityDemo";

public event Action<CloudXArbiterPlatform> AdLoaded;
public event Action<CloudXArbiterPlatform, string> AdLoadFailed;
public event Action<CloudXArbiterPlatform> AdClicked;
/* Every arbiter result, with the number of bids submitted. */
public event Action<CloudXArbiterResult, int> ArbiterCompleted;

protected string CloudXAdUnitId { get; }
protected string AdMobAdUnitId { get; }

/*
* When CloudX initialization failed, its callbacks may never fire and its
* bridge is not initialized, so the controller skips the CloudX leg and
* decides locally: AdMob is the only candidate, so AdMob wins.
*/
protected bool CloudXAvailable { get; }

protected bool IsDisposed { get; private set; }
protected bool IsLoadingCloudX { get; set; }
protected bool IsLoadingAdMob { get; set; }
protected bool ArbiterInFlight { get; private set; }

/* The CloudXAd from OnAdLoadSuccess; its AdValues carry the arbiter payload. */
protected CloudXAd LoadedCloudXAd { get; set; }

/*
* Bumped by Hide/Dispose so an arbiter result that was in flight at that
* moment is reported but never acted on.
*/
private int _generation;

protected ArbiterAdController(string cloudXAdUnitId, string adMobAdUnitId, bool cloudXAvailable)
{
CloudXAdUnitId = cloudXAdUnitId;
AdMobAdUnitId = adMobAdUnitId;
CloudXAvailable = cloudXAvailable;
}

protected static void Log(string message) => Debug.Log($"[{TAG}][Arbiter] {message}");

public abstract void Load();

protected void RaiseAdLoaded(CloudXArbiterPlatform platform) => AdLoaded?.Invoke(platform);
protected void RaiseAdLoadFailed(CloudXArbiterPlatform platform, string message) => AdLoadFailed?.Invoke(platform, message);
protected void RaiseAdClicked(CloudXArbiterPlatform platform) => AdClicked?.Invoke(platform);

protected void InvalidateInFlightArbiter() => _generation++;

/*
* Runs one arbiter round over the loaded candidates. The SDK owns the
* timeout and the fallback and always completes: one bid wins without a
* service call, several bids go to the arbiter service or, when it is
* unavailable, to the local highest-price fallback. Nothing here wraps the
* call in a timer or compares prices - the docs forbid both. The callback
* arrives on the Unity main thread.
*/
protected void RunArbiter(List<CloudXArbiterBid> bids, Action<CloudXArbiterResult> onResult)
{
ArbiterInFlight = true;
var generation = _generation;
Log($"Arbiter: {bids.Count} bid(s) for {CloudXAdUnitId}");

Decide(bids, result =>
{
ArbiterInFlight = false;

if (IsDisposed)
{
return;
}

Log($"Arbiter result: platform={result.Platform} platformName={result.PlatformName} " +
$"id={result.Id} bidId={result.BidId ?? "-"} bids={bids.Count}");
ArbiterCompleted?.Invoke(result, bids.Count);

if (generation == _generation)
{
onResult(result);
}
else
{
OnArbiterResultInvalidated();
}
});
}

/*
* A result arrived for a round that Hide() invalidated while it was in
* flight. Nothing may be shown from it, but the family may need to start the
* round the user asked for in the meantime.
*/
protected virtual void OnArbiterResultInvalidated()
{
}

private void Decide(List<CloudXArbiterBid> bids, Action<CloudXArbiterResult> onResult)
{
if (CloudXAvailable)
{
CloudXSdk.Arbiter(bids, onResult);
return;
}

/*
* CloudX never initialized, so the bids can only be the AdMob one. The
* SDK would select a lone bid without a service call anyway; deciding
* locally keeps the flow uniform without touching an uninitialized SDK.
*/
onResult(new CloudXArbiterResult(
Id: "local",
Platform: CloudXArbiterPlatform.AdMob,
PlatformName: "AdMob",
BidId: null,
Extras: new Dictionary<string, string>()));
}

/*
* An AdMob bid carries no price: CloudX prices it from the realized revenue
* reported through ReportAdMobPaidEvent. NetworkName is the ad source that
* filled, when Google exposes it.
*/
protected CloudXArbiterBid AdMobBid(string adSourceName) =>
new CloudXArbiterBid.AdMob(AdMobAdUnitId, NetworkName: adSourceName ?? "admob");

protected static string AdSourceName(ResponseInfo responseInfo) =>
responseInfo?.GetLoadedAdapterResponseInfo()?.AdSourceName;

/*
* Required part of the AdMob integration: forward Google's impression-level
* revenue so CloudX learns what AdMob demand actually pays. AdValue.Value is
* in micro-units of CurrencyCode.
*/
protected void ReportAdMobPaidEvent(AdValue adValue, string adFormat, string adSourceName)
{
if (!CloudXAvailable)
{
Log("AdMob paid event not forwarded: CloudX is not initialized");
return;
}

var accepted = CloudXSdk.ReportRevenueData(new CloudXRevenueData(
Platform: CloudXRevenuePlatform.AdMob,
Revenue: adValue.Value / 1_000_000.0,
AdFormat: adFormat,
CurrencyCode: adValue.CurrencyCode,
Precision: ToCloudXRevenuePrecision(adValue.Precision),
NetworkName: adSourceName,
AdUnitId: AdMobAdUnitId));

Log($"ReportRevenueData({adFormat}, {adValue.Value} micros {adValue.CurrencyCode}, " +
$"{adValue.Precision}) accepted={accepted}");
}

private static CloudXRevenuePrecision ToCloudXRevenuePrecision(AdValue.PrecisionType precision) =>
precision switch
{
AdValue.PrecisionType.Precise => CloudXRevenuePrecision.Exact,
AdValue.PrecisionType.Estimated => CloudXRevenuePrecision.Estimated,
AdValue.PrecisionType.PublisherProvided => CloudXRevenuePrecision.PublisherDefined,
_ => CloudXRevenuePrecision.Undefined,
};

/*
* Subscribe/unsubscribe the CloudX callbacks. Called from the concrete
* constructor (not here) so the subclass is fully constructed first.
*/
protected abstract void SubscribeCloudXCallbacks();
protected abstract void UnsubscribeCloudXCallbacks();
protected abstract void DestroyCloudXAd();
protected abstract void DestroyAdMobAd();

public void Dispose()
{
if (IsDisposed)
{
return;
}

IsDisposed = true;
InvalidateInFlightArbiter();

UnsubscribeCloudXCallbacks();

if (CloudXAvailable)
{
DestroyCloudXAd();
}

DestroyAdMobAd();
}
}
2 changes: 2 additions & 0 deletions Assets/Scripts/Arbiter/ArbiterAdController.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

113 changes: 113 additions & 0 deletions Assets/Scripts/Arbiter/ArbiterBannerController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using CloudX;
using GoogleMobileAds.Api;
using GoogleMobileAds.Common;

/*
* Arbiter/TPA banner. Shared flow lives in ArbiterInlineController; this class
* only supplies the banner SDK calls. Top banner on both SDKs. Auto-refresh is
* kept off on both sides - see the ArbiterInlineController class note; the
* crucial CloudX call is StopBannerAutoRefresh before create.
*/
public sealed class ArbiterBannerController : ArbiterInlineController
{
private const CloudXAdViewConfiguration.AdViewPosition CloudXPosition =
CloudXAdViewConfiguration.AdViewPosition.TopCenter;

private BannerView _adMobBanner;

public ArbiterBannerController(
string cloudXAdUnitId,
string adMobAdUnitId,
bool cloudXAvailable,
float refreshIntervalSeconds)
: base(cloudXAdUnitId, adMobAdUnitId, cloudXAvailable, refreshIntervalSeconds)
{
SubscribeCloudXCallbacks();
}

protected override string AdFormatName => "banner";

protected override void SubscribeCloudXCallbacks()
{
CloudXAdsCallbacks.Banner.OnAdLoadSuccess += CloudXOnLoadSuccess;
CloudXAdsCallbacks.Banner.OnAdLoadFailed += CloudXOnLoadFailed;
CloudXAdsCallbacks.Banner.OnAdClicked += CloudXOnClicked;
CloudXAdsCallbacks.Banner.OnAdRevenuePaid += CloudXOnRevenuePaid;
}

protected override void UnsubscribeCloudXCallbacks()
{
CloudXAdsCallbacks.Banner.OnAdLoadSuccess -= CloudXOnLoadSuccess;
CloudXAdsCallbacks.Banner.OnAdLoadFailed -= CloudXOnLoadFailed;
CloudXAdsCallbacks.Banner.OnAdClicked -= CloudXOnClicked;
CloudXAdsCallbacks.Banner.OnAdRevenuePaid -= CloudXOnRevenuePaid;
}

protected override void CloudXCreateAndLoad()
{
CloudXSdk.DestroyBanner(CloudXAdUnitId);

/*
* Required, not optional: CloudX banner auto-refresh is opt-out, so
* without this the first ShowBanner would start a background reload that
* races the arbiter. It goes before CreateBanner: the native layer
* registers the ad unit as refresh-disabled even with no view yet, then
* creates the view with refresh already off, so no timer ever runs.
* (Destroy clears that registration, hence this order.) It also unlocks
* the explicit LoadBanner used for later rounds.
*/
CloudXSdk.StopBannerAutoRefresh(CloudXAdUnitId);

/*
* Placement and custom data must be set before CreateBanner so they are
* on the first request. CreateBanner also issues the first load.
*/
CloudXSdk.SetBannerPlacement(CloudXAdUnitId, "arbiter_screen");
CloudXSdk.SetBannerCustomData(CloudXAdUnitId, "arbiter_banner_data");
CloudXSdk.CreateBanner(CloudXAdUnitId, new CloudXAdViewConfiguration(CloudXPosition));
}

protected override void CloudXLoad() => CloudXSdk.LoadBanner(CloudXAdUnitId);
protected override void CloudXShow() => CloudXSdk.ShowBanner(CloudXAdUnitId);
protected override void CloudXHide() => CloudXSdk.HideBanner(CloudXAdUnitId);
protected override void DestroyCloudXAd() => CloudXSdk.DestroyBanner(CloudXAdUnitId);

protected override void AdMobCreateHidden()
{
DestroyAdMobAd();

/*
* A BannerView loads once per LoadAd; there is no refresh API to turn off
* here. Its refresh is the ad unit's Automatic refresh setting in the
* AdMob console, which MUST be Disabled for this unit. Google Mobile Ads
* raises its callbacks off the Unity main thread; ExecuteInUpdate moves
* them back on.
*/
_adMobBanner = new BannerView(AdMobAdUnitId, AdSize.Banner, AdPosition.Top);

_adMobBanner.OnBannerAdLoaded += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobLoaded);

_adMobBanner.OnBannerAdLoadFailed += error => MobileAdsEventExecutor.ExecuteInUpdate(() =>
OnAdMobLoadFailed(error.GetMessage()));

_adMobBanner.OnAdClicked += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobClicked);
_adMobBanner.OnAdImpressionRecorded += () => MobileAdsEventExecutor.ExecuteInUpdate(OnAdMobImpression);

/* Required: this is how CloudX learns what the AdMob bid was worth. */
_adMobBanner.OnAdPaid += adValue => MobileAdsEventExecutor.ExecuteInUpdate(() => OnAdMobPaid(adValue));

/* Created hidden: a load must never render a view the arbiter did not pick. */
_adMobBanner.Hide();
}

protected override void AdMobLoad() => _adMobBanner.LoadAd(new AdRequest());
protected override void AdMobShow() => _adMobBanner.Show();
protected override void AdMobHide() => _adMobBanner?.Hide();
protected override ResponseInfo AdMobResponseInfo() => _adMobBanner?.GetResponseInfo();

protected override void DestroyAdMobAd()
{
_adMobBanner?.Destroy();
_adMobBanner = null;
}
}
2 changes: 2 additions & 0 deletions Assets/Scripts/Arbiter/ArbiterBannerController.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions Assets/Scripts/Arbiter/ArbiterConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Arbiter/TPA demo switches. The CloudX and AdMob ad unit ids both come from
* DemoConfig (the AdMob ones are Google's official test units).
*
* The AdMob banner and MREC units MUST have Automatic refresh set to Disabled
* in the AdMob console. The arbiter cycle owns refresh here: it decides when a
* new fill is requested and which network's view is on screen. An AdMob unit
* that refreshes on its own would swap the creative behind the arbiter's back.
*/
public static class ArbiterConfig
{
/*
* Flip to true to watch the single-bid path: CloudX is asked to fill an
* unknown ad unit and fails, so AdMob is the only bid in every round and the
* SDK selects it without a service call.
*/
public const bool ForceCloudXNoFill = false;

/*
* Banner and MREC re-arbitrate on this interval (docs recommend 20-30 s;
* shorter intervals decrease CPM performance).
*/
public const float InlineRefreshIntervalSeconds = 25f;

private const string InvalidCloudXAdUnitId = "arbiter-invalid-unit";

public static string CloudXAdUnitOrInvalid(string realAdUnitId) =>
ForceCloudXNoFill ? InvalidCloudXAdUnitId : realAdUnitId;
}
2 changes: 2 additions & 0 deletions Assets/Scripts/Arbiter/ArbiterConfig.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading