diff --git a/Assets/Plugins/Android/AndroidManifest.xml b/Assets/Plugins/Android/AndroidManifest.xml
index d7e8ed5..74b5a95 100755
--- a/Assets/Plugins/Android/AndroidManifest.xml
+++ b/Assets/Plugins/Android/AndroidManifest.xml
@@ -15,4 +15,10 @@
+
+
diff --git a/Assets/Scripts/DemoAdvertisingId.cs b/Assets/Scripts/DemoAdvertisingId.cs
new file mode 100644
index 0000000..56511a2
--- /dev/null
+++ b/Assets/Scripts/DemoAdvertisingId.cs
@@ -0,0 +1,293 @@
+using System;
+using System.Collections;
+using System.Threading;
+using UnityEngine;
+
+namespace CloudX.Demo
+{
+ /*
+ * Demo-only: reads this device's advertising ID so the demo can say whether the
+ * device can be registered as a CloudX test device.
+ *
+ * Test mode is server-controlled -- a device serves test ads because its advertising
+ * ID is on the dashboard's test-device list, not because of anything in this build.
+ * Nothing in the SDK surfaces that ID, so without this the only way to find it is to
+ * already know where to look.
+ *
+ * The case worth catching is the zeroed one. An opted-out device still returns an ID,
+ * but it returns ZeroedId -- the same all-zeros UUID on every opted-out device. It is
+ * a well-formed UUID, so it pastes into the dashboard without complaint and then
+ * matches nothing, which presents as ordinary no-fill and sends people to debug their
+ * integration instead of their consent flow.
+ *
+ * There is no one call that covers both platforms. Unity documents
+ * Application.RequestAdvertisingIdentifierAsync as "an advertising ID for iOS and
+ * UWP" -- it dropped the Android implementation years ago, and on Android the call
+ * returns false rather than the GAID. So only iOS uses that API, and Android asks
+ * Google Play services for AdvertisingIdClient directly. Keeping the two apart is
+ * also what lets each explain a zeroed ID in its own terms, which are not the same
+ * terms.
+ *
+ * Like DemoAppTrackingiOS, this is demo scaffolding and must never move into the
+ * CloudXSdk package.
+ */
+ public static class DemoAdvertisingId
+ {
+ /* What both platforms report when the device is opted out. */
+ public const string ZeroedId = "00000000-0000-0000-0000-000000000000";
+
+ /*
+ * Written by the resolver -- on Android from a worker thread, on iOS from a
+ * callback whose thread Unity does not document -- and read from the coroutine,
+ * i.e. on the Unity thread. _resolved is volatile and is written last, so a
+ * reader that sees it true also sees the three values behind it.
+ */
+ private static volatile bool _resolved;
+ private static string _id;
+ private static bool _trackingEnabled;
+ private static string _error;
+
+#if (UNITY_ANDROID || UNITY_IOS) && !UNITY_EDITOR
+ /*
+ * Whether the platform call has been issued. Only ever touched on the Unity
+ * thread, and it is what keeps a second caller -- or a first caller retrying
+ * after a timeout -- from starting a second worker. GeneralScene and
+ * FirstLookScene are separate scenes today so only one ever runs, but the ATT
+ * gate needs the same guard and this one costs nothing.
+ */
+ private static bool _started;
+#endif
+
+ public static string Id => _id;
+ public static bool TrackingEnabled => _trackingEnabled;
+ public static string Error => _error;
+ public static bool Resolved => _resolved;
+
+ /* A real ID that is worth registering on the dashboard. */
+ public static bool IsUsable =>
+ _resolved && string.IsNullOrEmpty(_error) && !string.IsNullOrEmpty(_id) && _id != ZeroedId;
+
+ /*
+ * Yields until the ID lands or the timeout expires, so a platform that never
+ * answers cannot hang the demo's startup. On timeout the state is deliberately
+ * left unresolved rather than marked failed: the answer usually arrives a moment
+ * later, and ShortStatus picks it up when initialization reports back.
+ */
+ public static IEnumerator Resolve(float timeoutSeconds = 3f)
+ {
+ if (_resolved)
+ {
+ yield break;
+ }
+
+#if (UNITY_ANDROID || UNITY_IOS) && !UNITY_EDITOR
+ if (!_started)
+ {
+ _started = true;
+ StartPlatformResolve();
+ }
+
+ var deadline = Time.realtimeSinceStartup + timeoutSeconds;
+ while (!_resolved && Time.realtimeSinceStartup < deadline)
+ {
+ yield return null;
+ }
+#else
+ /* Assigned explicitly so the Editor build does not warn them unassigned. */
+ _id = null;
+ _trackingEnabled = false;
+ _error = "no advertising ID in the Editor";
+ _resolved = true;
+ yield break;
+#endif
+ }
+
+#if UNITY_IOS && !UNITY_EDITOR
+ /*
+ * iOS only. This call is what the Android path exists to replace: Unity documents
+ * it as "an advertising ID for iOS and UWP" and returns false for it on Android,
+ * which reads as "no advertising ID on this device" and is not what is happening.
+ */
+ private static void StartPlatformResolve()
+ {
+ var started = Application.RequestAdvertisingIdentifierAsync(
+ (id, trackingEnabled, error) =>
+ {
+ _id = id;
+ _trackingEnabled = trackingEnabled;
+ _error = error;
+ _resolved = true;
+ });
+
+ if (!started)
+ {
+ _error = "the platform declined to report one";
+ _resolved = true;
+ }
+ }
+#endif
+
+#if UNITY_ANDROID && !UNITY_EDITOR
+ /*
+ * AdvertisingIdClient.getAdvertisingIdInfo binds to Google Play services and
+ * throws IllegalStateException if it is called on the main thread, so it runs on a
+ * worker. The thread has to be attached to the JVM by hand for JNI to work, and
+ * every AndroidJavaObject it creates is disposed on that same thread -- only
+ * plain strings and bools cross back.
+ *
+ * Background so a Play services bind that never returns cannot hold the process
+ * open, and fire-and-forget because the coroutine above is what waits.
+ */
+ private static void StartPlatformResolve()
+ {
+ var worker = new Thread(() =>
+ {
+ AndroidJNI.AttachCurrentThread();
+ try
+ {
+ using var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
+ using var activity = player.GetStatic("currentActivity");
+ using var client =
+ new AndroidJavaClass("com.google.android.gms.ads.identifier.AdvertisingIdClient");
+ using var info = client.CallStatic("getAdvertisingIdInfo", activity);
+
+ _id = info.Call("getId");
+ /*
+ * Limit ad tracking is the pre-Android-12 form of opting out: the ID
+ * stays real but must not be used for ads. From Android 12 the ID
+ * itself zeroes instead, so on a modern device this is normally false
+ * whenever _id is usable.
+ */
+ _trackingEnabled = !info.Call("isLimitAdTrackingEnabled");
+ }
+ catch (Exception e)
+ {
+ _error = DescribeAndroidFailure(e);
+ }
+ finally
+ {
+ /* Last, and volatile, so the reader sees the values above it. */
+ _resolved = true;
+ AndroidJNI.DetachCurrentThread();
+ }
+ })
+ {
+ IsBackground = true,
+ Name = "CloudXUnityDemoAdvertisingId"
+ };
+
+ worker.Start();
+ }
+
+ /*
+ * Unity surfaces a Java throwable as AndroidJavaException whose message opens with
+ * the Java class name, so match on that. Each of these is a different thing to go
+ * and fix, which "unavailable" on its own would not tell anyone.
+ */
+ private static string DescribeAndroidFailure(Exception e)
+ {
+ var message = e.Message ?? string.Empty;
+
+ if (message.Contains("GooglePlayServicesNotAvailableException"))
+ {
+ return "no Google Play services on this device";
+ }
+
+ if (message.Contains("GooglePlayServicesRepairableException"))
+ {
+ return "Google Play services needs updating";
+ }
+
+ if (message.Contains("ClassNotFoundException") || message.Contains("NoClassDefFoundError"))
+ {
+ return "play-services-ads-identifier is not on the classpath";
+ }
+
+ if (message.Contains("IOException"))
+ {
+ return "could not reach Google Play services";
+ }
+
+ return message.Length == 0 ? e.GetType().Name : message;
+ }
+#endif
+
+ /*
+ * One line for the log: the full ID, which is the value to paste into the
+ * dashboard, plus what to do about it.
+ */
+ public static string Describe()
+ {
+ if (!_resolved)
+ {
+ return "not resolved yet";
+ }
+
+ if (!string.IsNullOrEmpty(_error))
+ {
+ return $"unavailable ({_error})";
+ }
+
+ if (string.IsNullOrEmpty(_id))
+ {
+ return "unavailable (no ID reported)";
+ }
+
+ if (_id == ZeroedId)
+ {
+ return $"{_id} - ZEROED, cannot be whitelisted ({ZeroedCause()})";
+ }
+
+ if (!_trackingEnabled)
+ {
+ /*
+ * A real ID the device has told us not to use for ads -- the
+ * pre-Android-12 form of opting out. Registering it works, so this is not
+ * the zeroed case, but fill will still look broken for a reason that has
+ * nothing to do with the dashboard.
+ */
+ return $"{_id} - registerable, but this device is opted out of tracking, " +
+ "so bid requests go out as do-not-track and will not fill";
+ }
+
+ return $"{_id} - register this on the CloudX dashboard to serve test ads";
+ }
+
+ /*
+ * The two platforms zero the ID for different reasons and are fixed in different
+ * places, so say which one applies rather than the union of both.
+ */
+ private static string ZeroedCause()
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ return "turn on ad personalization in Settings > Google > Ads, and check the app declares " +
+ "com.google.android.gms.permission.AD_ID";
+#elif UNITY_IOS && !UNITY_EDITOR
+ return "App Tracking Transparency was not authorized; reinstall to be asked again";
+#else
+ return "the device is opted out of tracking";
+#endif
+ }
+
+ /* Short enough for the on-screen status line, which is narrow in landscape. */
+ public static string ShortStatus()
+ {
+ if (!_resolved)
+ {
+ return null;
+ }
+
+ if (!string.IsNullOrEmpty(_error) || string.IsNullOrEmpty(_id))
+ {
+ return "Ad ID unavailable";
+ }
+
+ if (_id == ZeroedId)
+ {
+ return "Ad ID zeroed - see log";
+ }
+
+ return _trackingEnabled ? "Ad ID ok" : "Ad ID no-track - see log";
+ }
+ }
+}
diff --git a/Assets/Scripts/DemoAdvertisingId.cs.meta b/Assets/Scripts/DemoAdvertisingId.cs.meta
new file mode 100644
index 0000000..893d037
--- /dev/null
+++ b/Assets/Scripts/DemoAdvertisingId.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bbd38fd555164d05a7e9634c8445db22
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/DemoConfig.cs b/Assets/Scripts/DemoConfig.cs
index d80d42d..bf0779d 100644
--- a/Assets/Scripts/DemoConfig.cs
+++ b/Assets/Scripts/DemoConfig.cs
@@ -2,6 +2,13 @@
* Demo dashboard IDs so this sample runs without a CloudX account.
* In your game, replace these with the app key and ad unit IDs from your
* CloudX dashboard. Use one app key per process.
+ *
+ * Test mode is server-controlled: register this device's advertising ID in
+ * the CloudX dashboard, not here. That ID reads back as all zeros on an
+ * opted-out device -- App Tracking Transparency declined on iOS, ad
+ * personalization off on Android -- and a zeroed ID is a well-formed UUID
+ * that the dashboard accepts and then never matches. The demo logs the ID
+ * and says which of the two you have; see README.md > Test devices.
*/
public static class DemoConfig
{
diff --git a/Assets/Scripts/Editor/CloudXDemoDependencies.xml b/Assets/Scripts/Editor/CloudXDemoDependencies.xml
index db75957..099b761 100644
--- a/Assets/Scripts/Editor/CloudXDemoDependencies.xml
+++ b/Assets/Scripts/Editor/CloudXDemoDependencies.xml
@@ -7,6 +7,15 @@
https://artifact.bytedance.com/repository/pangle
https://artifact.taurusx.com/artifactory/taurusx-sdk/
+
+
+
diff --git a/Assets/Scripts/FirstLook/FirstLookScreen.cs b/Assets/Scripts/FirstLook/FirstLookScreen.cs
index 0a5543b..c339ee6 100644
--- a/Assets/Scripts/FirstLook/FirstLookScreen.cs
+++ b/Assets/Scripts/FirstLook/FirstLookScreen.cs
@@ -74,6 +74,16 @@ IEnumerator Start()
*/
yield return DemoAppTrackingiOS.EnsureRequested();
+ /*
+ * Log the advertising ID once tracking has been answered. This screen has its
+ * own CloudXSdk.Initialize, so it needs the same read the General screen does -
+ * the CloudX leg no-fills the same way when the device is not whitelisted.
+ * Resolve is idempotent, and the status line here is already the two-part
+ * "CloudX | AdMob" summary, so the verdict stays in the log.
+ */
+ yield return DemoAdvertisingId.Resolve();
+ Log($"Advertising ID: {DemoAdvertisingId.Describe()}");
+
if (!DemoAppTrackingiOS.IsUsable(DemoAppTrackingiOS.Status))
{
Log($"Tracking not authorized ({DemoAppTrackingiOS.Status}), leaving the UI disabled");
diff --git a/Assets/Scripts/GeneralScreen.cs b/Assets/Scripts/GeneralScreen.cs
index e945a4b..646448f 100644
--- a/Assets/Scripts/GeneralScreen.cs
+++ b/Assets/Scripts/GeneralScreen.cs
@@ -75,6 +75,16 @@ IEnumerator Start()
*/
yield return DemoAppTrackingiOS.EnsureRequested();
+ /*
+ * Read the advertising ID once tracking has been answered, and log it in full.
+ * This is the value that has to be on the dashboard's test-device list for the
+ * device to serve test ads, and it is worth logging even when tracking was
+ * declined: a zeroed ID is the specific symptom that looks like a wrong
+ * dashboard entry rather than a consent problem.
+ */
+ yield return DemoAdvertisingId.Resolve();
+ Log($"Advertising ID: {DemoAdvertisingId.Describe()}");
+
if (!DemoAppTrackingiOS.IsUsable(DemoAppTrackingiOS.Status))
{
/*
@@ -199,7 +209,15 @@ private void OnSdkInitialized(CloudXSdkConfiguration config)
{
Log("CloudX SDK initialized successfully");
_initAnswered = true;
- _ui.SetInitializationStatus("Status: Initialized");
+ /*
+ * Carry the advertising-ID verdict on the status line. A zeroed ID does not stop
+ * initialization -- it stops the device from ever matching the dashboard's
+ * test-device list -- so this is the only place the demo can flag it before the
+ * tester concludes their ad units are wrong.
+ */
+ var adId = DemoAdvertisingId.ShortStatus();
+ _ui.SetInitializationStatus(
+ string.IsNullOrEmpty(adId) ? "Status: Initialized" : $"Status: Initialized - {adId}");
_ui.SetActionsInteractable(true);
InitializeBannerAds();
diff --git a/README.md b/README.md
index a24b2d7..d668e0b 100644
--- a/README.md
+++ b/README.md
@@ -163,6 +163,30 @@ app or the SDK gets no fill.
The AdMob ad units in `FirstLookConfig.cs` are Google's official test units and stay valid as they
are; replace them with your own AdMob units when you take this into production.
+### Test devices
+
+Test mode is server-controlled. A device serves CloudX test ads because its advertising ID is on the
+test-device list in your dashboard, not because of anything in this build — there is no code change
+that turns it on.
+
+The trap is that the advertising ID reads back as all zeros
+(`00000000-0000-0000-0000-000000000000`) when the device is opted out. That is a well-formed UUID, so
+it pastes into the dashboard without complaint and then matches nothing, which looks exactly like a
+wrong dashboard entry rather than a consent problem.
+
+So the demo reads the ID for you. It logs it in full at startup under
+`[CloudXUnityDemo] Advertising ID:` — that string is what you paste into the dashboard — and the
+status line on the General screen carries a short verdict. The verdict is about the health of the
+ID, never about whether your dashboard entry took effect: no CloudX SDK exposes the resolved test
+flag, so "ok" means the ID is worth registering, not that it is registered.
+
+If it comes back zeroed, check the device:
+
+| Platform | The ID zeroes when |
+| --- | --- |
+| iOS | App Tracking Transparency was not authorized. The demo prompts on launch; iOS only asks once per install, so a refusal needs a reinstall to undo. |
+| Android | Ad personalization is off (Settings > Google > Ads > Delete advertising ID), or the app targets SDK 33+ without declaring `com.google.android.gms.permission.AD_ID`. |
+
### iOS target SDK
The project is configured for the **Simulator** SDK. To build for a physical iOS device, switch