Surface test-device whitelisting: document it, and report the advertising ID - #3
Surface test-device whitelisting: document it, and report the advertising ID#3nieny225 wants to merge 3 commits into
Conversation
antonurankar-moloco
left a comment
There was a problem hiding this comment.
This solution doesn't support Android but only iOS.
Unity removed Android support from this API around Unity version 2020
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.
9cdb79c to
84037cf
Compare
|
@antonurankar-moloco You're right, thanks — and the PR description was wrong about it too, since it claimed Fixed in 84037cf by splitting the resolve. iOS keeps the Unity API; Android goes to 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 Branch is rebased onto main (past #4 and #5, so the hunks moved to |
There was a problem hiding this comment.
🟡 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.mdandDemoConfig.cs. - Added a new
DemoAdvertisingIdutility 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()}"); |
…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.
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.csalready resolves ATT beforeInitializefor exactly this class of reason, but nothing connected that to whitelisting.What changed
Docs — a
### Test devicessection inREADME.md, and the same point in theDemoConfig.csheader 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 callCloudXSdk.Initialize(GeneralScreen,FirstLookScreen), and carries a short verdict on the General screen's initialization status line.Reading the advertising ID on each platform
Application.RequestAdvertisingIdentifierAsyncis documented as "an advertising ID for iOS and UWP" — Unity dropped the Android implementation, and on Android the call returnsfalse. So the resolve is split:AdvertisingIdClient.getAdvertisingIdInfo. That throws if called on the main thread, so it runs on a background worker attached to the JVM by hand; everyAndroidJavaObjectis disposed on that same thread and only strings and bools cross back, behind avolatileflag written last. The existingResolve()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-identifierreached the classpath only transitively, via the Google Mobile Ads plugin that just the First Look flow needs. Now declared inCloudXDemoDependencies.xml.com.google.android.gms.permission.AD_IDlikewise 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 —
CloudXSdkConfigurationis 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
adb shell settings get secure advertising_idIllegalStateException: Calling this from your main thread…and no ANR in logcatCloudX | AdMobstatus line is untouchedCompile-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_ANDROIDand withUNITY_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
mainafter #4 and #5, which renamedHomeScreen.cs→GeneralScreen.csand added the First Look flow.🤖 Generated with Claude Code