Skip to content

Surface test-device whitelisting: document it, and report the advertising ID - #3

Open
nieny225 wants to merge 3 commits into
mainfrom
docs/test-device-whitelisting-and-ifa-caveat
Open

Surface test-device whitelisting: document it, and report the advertising ID#3
nieny225 wants to merge 3 commits into
mainfrom
docs/test-device-whitelisting-and-ifa-caveat

Conversation

@nieny225

@nieny225 nieny225 commented Aug 24, 2026

Copy link
Copy Markdown

Why

The demo README covers how a publisher points the sample at their own CloudX app — replace the app key and ad unit IDs in DemoConfig.cs, set the matching bundle identifier — and explains that bid requests are authorized per app key and bundle ID.

What it doesn't say is that test mode is server-controlled. A publisher who swaps the demo IDs for their own still has to register the device's advertising ID in the CloudX dashboard; there's no code change that does it.

The second half is the part that costs real debugging time: the advertising ID reads back as all zeros on an opted-out device. A zeroed ID is a well-formed UUID — it pastes into the dashboard without complaint and then matches nothing, so the failure is indistinguishable from a wrong dashboard entry. GeneralScreen.cs already resolves ATT before Initialize for exactly this class of reason, but nothing connected that to whitelisting.

What changed

Docs — a ### Test devices section in README.md, and the same point in the DemoConfig.cs header comment since that's the file a publisher actually opens to swap the IDs.

Code — the demo logs the advertising ID in full at startup ([CloudXUnityDemo] Advertising ID: …) from both entry points that call CloudXSdk.Initialize (GeneralScreen, FirstLookScreen), and carries a short verdict on the General screen's initialization status line.

Reading the advertising ID on each platform

Application.RequestAdvertisingIdentifierAsync is documented as "an advertising ID for iOS and UWP" — Unity dropped the Android implementation, and on Android the call returns false. So the resolve is split:

  • iOS keeps the Unity API.
  • Android asks Google Play services for AdvertisingIdClient.getAdvertisingIdInfo. That throws if called on the main thread, so it runs on a background worker attached to the JVM by hand; every AndroidJavaObject is disposed on that same thread and only strings and bools cross back, behind a volatile flag written last. The existing Resolve() coroutine polls that flag exactly as the ATT gate polls its own.

Splitting the platforms is also what makes the advice correct, which one shared call could not be. A zeroed ID means ATT was declined on iOS and ad personalization is off on Android — different settings, different screens — and the union of both was wrong on either. Android also has a case iOS does not: a real ID with limit ad tracking on, which is registerable but still bids do-not-track, so it reads as a dashboard problem and is not one.

Two supporting declarations, neither of which changes the merged manifest today:

  • play-services-ads-identifier reached the classpath only transitively, via the Google Mobile Ads plugin that just the First Look flow needs. Now declared in CloudXDemoDependencies.xml.
  • com.google.android.gms.permission.AD_ID likewise arrived only from that library's manifest. Declaring it stops the target-SDK-33+ requirement (this project targets 36) being something a publisher inherits by accident.

What it deliberately does not claim

It reports the health of the ID, never that the device is registered. No CloudX SDK exposes the resolved test flag — CloudXSdkConfiguration is an empty record here, and the Android and iOS counterparts are equally empty — so "ok" means the ID is worth registering, not that the dashboard entry took effect. Claiming otherwise would send people to debug their integration when their account is the problem.

A timeout now leaves the state unresolved rather than recording a failure, so a late answer still reaches the status line instead of being poisoned by a "timed out" error.

Test plan

  • Build to an Android device, confirm the logged ID matches adb shell settings get secure advertising_id
  • Turn off ad personalization (Settings → Google → Ads → Delete advertising ID), relaunch, confirm the zeroed verdict names the Android cause
  • On a pre-Android-12 device, opt out of ads personalization and confirm the limit-ad-tracking verdict
  • Confirm no IllegalStateException: Calling this from your main thread… and no ANR in logcat
  • Build to an iOS device, deny ATT (or reinstall), confirm the zeroed verdict names ATT
  • Confirm the status line stays readable in landscape, where it compacts to 300x28
  • Confirm the First Look screen logs the ID and its CloudX | AdMob status line is untouched

Compile-checked so far: Unity 6000.0.60f1 batch import (Editor target, 0 errors, no warnings in these files), plus the demo scripts compiled against the project's own assemblies with UNITY_ANDROID and with UNITY_IOS. Device runs above are still to do.

Notes

Found while using this demo as a reference integration. The equivalent lands in the CloudX Android, iOS and Unity sample apps so the four stay consistent.

Rebased onto main after #4 and #5, which renamed HomeScreen.csGeneralScreen.cs and added the First Look flow.

🤖 Generated with Claude Code

@nieny225 nieny225 changed the title Document test-device whitelisting and the IFA-non-zero caveat Surface test-device whitelisting: document it, and report the advertising ID Aug 25, 2026

@antonurankar-moloco antonurankar-moloco left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solution doesn't support Android but only iOS.
Unity removed Android support from this API around Unity version 2020

nieny225 and others added 3 commits September 3, 2026 14:52
The demo explains how to swap in your own app key, ad unit IDs and bundle
identifier, but nothing says test mode is server-controlled — a publisher
replacing the demo IDs with their own has to register the device's advertising
ID in the CloudX dashboard as well.

Adds that, plus the part that is easy to lose an afternoon to: the advertising
ID reads back as all zeros until tracking consent is granted on the device (App
Tracking Transparency on iOS, ad-personalization consent on Android), so a
device can silently fail whitelisting for a reason that looks identical to a
wrong dashboard entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Qe7sVn74q6KtnJiRk5P2c
The README now tells a publisher the advertising ID has to be on the
dashboard's test-device list and that it reads back as all zeros without
tracking consent. Neither is checkable from the demo: nothing prints the ID,
and a zeroed one is invisible unless you already recognize the all-zeros UUID
on sight. It is well-formed, so it pastes into the dashboard without complaint
and then matches nothing -- which looks like a wrong dashboard entry rather
than a consent problem.

The demo now logs the ID in full at startup and carries a short usable/zeroed
verdict on the existing status line.

Deliberately not a new screen or a new scene control: the UI comes from
HomeScene.unity as serialized fields, with no scrollable area, and the
landscape reflow snapshots controls at Bind time -- a runtime-added label
would sit outside that and risk the rotated layout. The log is where this demo
already surfaces everything, having no on-screen log at all.

Application.RequestAdvertisingIdentifierAsync covers both platforms in one
call, so this needs no native plugin and no AdSupport link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Qe7sVn74q6KtnJiRk5P2c
Application.RequestAdvertisingIdentifierAsync is documented as "an advertising
ID for iOS and UWP". Unity dropped the Android implementation, so on Android the
call returns false and the demo reported "unavailable (not available on this
platform)" -- on the platform where CloudX test-device whitelisting is most
often what someone is debugging.

Split the resolve by platform. iOS keeps the Unity API; Android asks Google Play
services for AdvertisingIdClient.getAdvertisingIdInfo, which throws if called on
the main thread, so it runs on a background worker attached to the JVM by hand.
Only strings and bools cross back to the Unity thread, behind a volatile flag
written last, and the coroutine polls that flag the way the ATT gate already
polls its own. A timeout now leaves the state unresolved rather than recording a
failure, so a late answer still reaches the status line.

Splitting the platforms is also what makes the advice correct. A zeroed ID means
ATT was declined on iOS and ad personalization is off on Android -- different
settings, different screens -- and the union of both was wrong on either. Android
adds a case iOS does not have: a real ID with limit ad tracking on, which is
registerable but still bids do-not-track, so it reads as a dashboard problem and
is not one.

play-services-ads-identifier only reached the classpath transitively through the
Google Mobile Ads plugin, which just the First Look flow needs, so declare it.
com.google.android.gms.permission.AD_ID likewise arrived only from that library's
manifest; declaring it changes nothing in the merged manifest but stops the
target-SDK-33+ requirement being something a publisher inherits by accident.

FirstLookScreen has its own CloudXSdk.Initialize, so it logs the ID as well.
@nieny225
nieny225 force-pushed the docs/test-device-whitelisting-and-ifa-caveat branch from 9cdb79c to 84037cf Compare September 3, 2026 06:59
@nieny225

nieny225 commented Sep 3, 2026

Copy link
Copy Markdown
Author

@antonurankar-moloco You're right, thanks — and the PR description was wrong about it too, since it claimed RequestAdvertisingIdentifierAsync was one cross-platform call. Unity's own reference now reads "an advertising ID for iOS and UWP", and on Android the call just returns false, so the demo was logging unavailable (not available on this platform) on the platform where test-device whitelisting is most often what someone is debugging.

Fixed in 84037cf by splitting the resolve. iOS keeps the Unity API; Android goes to AdvertisingIdClient.getAdvertisingIdInfo in Play services, on a background worker with a manual AndroidJNI.AttachCurrentThread because that call throws if it's made on the main thread. Only strings and bools come back to the Unity thread, behind a volatile flag written last, and the existing coroutine polls it the way the ATT gate already polls its own.

Splitting them also fixed the wording, which one shared call couldn't have: a zeroed ID means ATT was declined on iOS and ad personalization is off on Android, so the demo now names the right setting per platform. Android also gets a case iOS doesn't have — a real ID with limit ad tracking on, registerable but still bidding do-not-track.

Also declared play-services-ads-identifier and com.google.android.gms.permission.AD_ID explicitly. Both only reached the build transitively through the Google Mobile Ads plugin that just the First Look flow needs, so neither changes the merged manifest — it's so a publisher copying this sees the target-SDK-33+ requirement instead of inheriting it.

Branch is rebased onto main (past #4 and #5, so the hunks moved to GeneralScreen.cs and FirstLookScreen.cs now logs it too). Compile-checked for both player targets; device runs are still on the test plan.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There’s an Android worker-thread error path that can leave the ad ID permanently unresolved, and the First Look advertising-ID log prefix currently doesn’t match what the README instructs users to search/copy.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves the Unity demo’s “test mode” troubleshooting by documenting server-controlled test-device whitelisting and surfacing the device advertising ID (plus a short “health” verdict) so publishers can correctly register test devices in the CloudX dashboard.

Changes:

  • Documented CloudX test-device behavior and the “all-zero” advertising ID trap in README.md and DemoConfig.cs.
  • Added a new DemoAdvertisingId utility and integrated it into both demo entry points to log the advertising ID at startup; General screen also appends a short verdict to the initialization status.
  • Declared Android dependencies/permissions needed to reliably read the advertising ID (play-services-ads-identifier + com.google.android.gms.permission.AD_ID).
File summaries
File Description
README.md Adds a “Test devices” section explaining whitelisting and zeroed ID behavior.
Assets/Scripts/GeneralScreen.cs Resolves + logs advertising ID before init; appends ad-ID verdict to init status.
Assets/Scripts/FirstLook/FirstLookScreen.cs Resolves + logs advertising ID before init for the First Look flow.
Assets/Scripts/Editor/CloudXDemoDependencies.xml Declares play-services-ads-identifier explicitly for the demo.
Assets/Scripts/DemoConfig.cs Adds header guidance about server-controlled test mode and zeroed IDs.
Assets/Scripts/DemoAdvertisingId.cs New demo-only cross-platform advertising ID resolver (Unity API on iOS, Play Services on Android).
Assets/Scripts/DemoAdvertisingId.cs.meta Meta for the new script asset.
Assets/Plugins/Android/AndroidManifest.xml Explicitly declares com.google.android.gms.permission.AD_ID for targetSdk 33+.
Review details

Files not reviewed (1)

  • Assets/Scripts/DemoAdvertisingId.cs.meta: Generated file
  • Files reviewed: 7/8 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

{
var worker = new Thread(() =>
{
AndroidJNI.AttachCurrentThread();
* "CloudX | AdMob" summary, so the verdict stays in the log.
*/
yield return DemoAdvertisingId.Resolve();
Log($"Advertising ID: {DemoAdvertisingId.Describe()}");
antonurankar-moloco added a commit that referenced this pull request Sep 7, 2026
…cle its own file

Hiding a First Look banner was supposed to stop it requesting. It did not, in four separate ways — one reported, three found in review on this PR. Each was reproduced on device against a control build rather than argued from reading the code.

Along the way the host half of the banner contract became a real file, `FirstLookBannerCycle.cs`, which is what lets the docs page link it instead of pasting a copy that nothing compiles.

## 1. The reported defect: a retry armed on a hidden slot

`FirstLookScreen` had two places arming the same retry, and only one knew about the hide. `ToggleBanner` called `CancelInvoke(nameof(LoadBanner))`, which empties Unity's invoke queue and nothing else; the `AdLoadFailed` handler armed a new load unconditionally. A load already out on the network is not in that queue, so it failed after the hide and armed a fresh request against an off-screen slot. With both sources no-filling, the backoff climbed to its 60s cap and repeated until the scene was destroyed.

It also broke a contract the README stated: "Hiding cancels the pending pass, so a hidden slot never keeps requesting in the background."

Reproduced against the unmodified parent commit, network killed so both legs no-fill:

```
09:29:47.513  Banner load failed (AdMob); retrying in 16s
   >>> 09:30:12.87  Hide tapped, load in flight <<<
09:30:15.349  Banner load failed (AdMob); retrying in 32s   <- armed on a hidden slot
09:30:58.580  Banner load failed (AdMob); retrying in 60s
09:32:09.814  Banner load failed (AdMob); retrying in 60s
09:33:21.513  Banner load failed (AdMob); retrying in 60s
```

13 SDK-level request lines after the hide, with the slot empty.

**One correction to the original report.** It claimed a fill in this state means the demo "bought an ad for a hidden banner and spent the pass on nothing". It does not: `KeepsUnspentFill` banks it, `ReadySource` goes non-null, `Load()` early-returns and the loop stops. Only the sustained no-fill branch was broken.

## 2-5. Four more, found in review

The first fix was necessary but not sufficient. Copilot found #2-#4; #5 came out of my own final pass, after Copilot's reviewer went down. All four were real and reachable.

| # | Defect | Control build | Fixed |
| --- | --- | --- | --- |
| 2 | The retry guard only ran after `AdLoadFailed`, which the controller raises only when the **AdMob** leg fails. Hiding while the **CloudX** leg was in flight left `CloudXOnLoadFailed` free to start the fallback on an off-screen slot. | 1 AdMob request, 1.2s after the hide | 0 |
| 3 | The fix for #2 cleared its flag in `Show()` — current visibility, not whether the running *pass* was cancelled. Hide then a quick re-show revived it. | 2 AdMob requests | 0 |
| 4 | Only *failure* callbacks were gated. A stale **success** still raised `PassSpent` and reset the cooldown from a pass the player had dismissed. | walked against the code; regression-verified | no re-timing |
| 5 | **A regression from fixing #2-#4.** Suppressing a cancelled pass's terminal callback could strand the cycle with an ad still on screen. Hide, then Show, leaves the flag set while the cancelled load is still on the network; the Show arms the cooldown; that tick finds the load in flight, `Load()` early-returns, the flag is never cleared, and the stale completion is then dropped - so no `PassSpent` and no `AdLoadFailed` reach the host and nothing is pending. | code trace; not reproduced on device | cycle keeps turning |

The root cause of #4 is worth naming: one flag carried two meanings — "this controller asked for the load", which decides whether a fill is banked, and "this should re-time the cycle". They come apart in exactly one case, a pass a `Hide` cancelled: it is still ours, but its timing is not. They are two flags now, so a cancelled pass's fill is still banked while hidden and still shows if the slot has since been shown — discarding an ad we paid a request for would be worse — but the cooldown stays with whatever the host scheduled after the hide.

The flag that ties #2#5 together is pass-scoped: `Hide()` sets it and **any `Load()` clears it**, including one the in-flight guard then drops. That last part is what fixes #5: a load the host asks for means the slot is wanted again, so it adopts the pass still out on the network instead of silencing it, and that pass's completion carries the cycle forward. `Show()` deliberately does not clear it, so a Hide with no following Load still cancels the pass completely. `_wantShown` cannot do this job at all, because it is also false during the preload, and the preload has to reach the fallback — that is what banks an ad for the first tap.

On `main` the #5 window self-healed, through the very defect #2 fixes: the stale failure fell through to the AdMob fallback, that fill rendered into the visible view and `PassSpent` re-armed the cooldown. So #5 is a resilience regression this PR introduced and then removed, not a pre-existing gap — I had it filed as the latter at first, which was wrong.

## The extraction

The clock only existed inside `FirstLookScreen`, mixed in with SDK init, the ATT gate, the interstitial and UI plumbing. That is why the docs page had to paste a host snippet — and a pasted snippet is a second copy, which had drifted into declaring `FirstLookBannerController.Source`, a type that does not exist, because nothing compiled it.

`FirstLookBannerCycle.cs` is that clock as a `MonoBehaviour`, in one file: the cooldown, the retry backoff, the wanted flag and the show/hide toggle. It depends on nothing but `FirstLookBannerController` and `FirstLookSource`, so the docs link it rather than copy it. `FirstLookScreen` drops from 390 lines to 332 and keeps only initialization, button binding and status text.

It is named for what it holds. It was `FirstLookBannerHud` first, which was wrong — it has no UI at all, and `AdScreenUi` is the file that actually owns the labels and buttons.

This is the smaller of two options. It removes one class of drift, but publishers still hand-write the host. Folding the cycle into the controller so there is no host contract at all is the follow-up, once this squashes.

## `ForceCloudXNoFill` removed

An internal test switch that does not belong in the public sample. `FirstLookConfig` is now just the AdMob fallback ad unit ids — the pass cooldown moved into the cycle that paces it. The README points at `DemoConfig` for the same effect, which is what the docs page already tells publishers to do.

## Interstitial: not the same hole

No change. It arms retries the same unconditional way, but a fullscreen ad has no visibility state — no `Hide`, nothing to cancel — and keeping one preloaded is the intent. Its "copy two files" header is also still accurate, because it needs no clock. One adjacent thing checked: neither handler calls `CancelInvoke`, so two `LoadInterstitial` invokes can stack; the second is absorbed by the guards at `FirstLookInterstitialController.cs:96`. Harmless.

## Verification

Final code re-verified on instances created for it, because another session held the ones I had been using: `emulator-5556` (AVD `Pixel_2_API_33`), a physical Pixel 10 Pro, and a fresh `FirstLookVerify` simulator (iPhone 17 Pro, iOS 26.2).

| Check | emulator-5556 | Pixel 10 Pro | iOS simulator |
| --- | --- | --- | --- |
| Pass cadence, shown | 29.8s, 30.0s, 29.9s | 30.0s, 30.2s over 6 passes | 30.1s, 31.6s |
| Hide -> CloudX / AdMob requests | 0 / 0 over 88s | 0 / 0 over 76s | 0 requests, 0 revenue events over 85s |
| Re-show | banked ad back, next pass +29.9s | +30.1s | +30.5s |
| Interstitial | shown (CloudX) | shown (CloudX) | shown (CloudX) |
| Batchmode compile | 0 `error CS` | — | — |

Markers: `[CXAdView] ... load()` in logcat on Android; on iOS the CloudX internals are `os_log` at debug level, which `log show` cannot recover after the fact, so those counts come from a live `log stream --level debug` started before each window and confirmed alive at the end of it. Unity's own `Debug.Log` on iOS is printf to stderr and comes from `simctl launch --console-pty`.

The earlier control-build numbers for #1-#4 stand as recorded in the sections above; they were taken against control builds of the revisions that carried each defect. Reproducing #2 and #3 needed care: with an invalid ad unit the CloudX leg is only ~33ms, so a hide cannot land inside it. Using the real ad unit with the network off stretches the leg to 1-2s through an `HttpRetry` cycle, and the taps were triggered off the `[CXAdView] load()` log line rather than timed by hand.

**Not reproduced:** the three race cases and the preload retry need the network down, and the iOS simulator has no per-device network lever, so those are Android-only evidence — I am not arguing them on iOS from equivalence, they were not observed there either way. #5 is not reproduced on either platform: it needs a load outstanding past the 30s cooldown on a slot that has been hidden and shown again, and I did not manufacture that window. It rests on the code trace above plus the non-regression runs, and I would rather say so than imply a capture exists.

**A correction to earlier evidence in this PR.** An earlier revision logged `not retrying while hidden`, and I cited it. Once the controller began dropping the terminal failure of a cancelled pass, that branch became unreachable and the current build cannot emit it. The dead branch is gone, and what stands in its place is the request count — which is the thing that actually matters.

## Scope

| File | Change |
| --- | --- |
| `FirstLookBannerCycle.cs` | New. The pass cycle, extracted from the screen. |
| `FirstLookBannerController.cs` | **Changed.** Pass-cancellation state; both CloudX and AdMob failure paths, and both success paths, now distinguish a cancelled pass. |
| `FirstLookScreen.cs` | Delegates the banner to the cycle; keeps init, buttons, status text. |
| `FirstLookConfig.cs` | Loses the pass cooldown and the internal no-fill switch. |
| `README.md` | File table, copy instructions, hide rule. |

No scene and no `ProjectSettings`. `FirstLookInterstitialController.cs` untouched.

## Follow-ups, not in this PR

- No per-pass timeout. A load whose callback never arrives strands `_isLoadingCloudX` set for the life of the controller, so every later `Load()` is dropped. Genuinely pre-existing, and distinct from #5: #5 was about the *flag* outliving a pass that did complete, which is fixed.
- Folding the cycle into the controller, so publishers have no host contract to get wrong at all.

Companion docs PR: cloudx-io/docs#407 — **merge this one first**, or its links 404.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants