diff --git a/Assets/AirConsole/scripts/Runtime/AirConsole.cs b/Assets/AirConsole/scripts/Runtime/AirConsole.cs index bda49b8..b982beb 100644 --- a/Assets/AirConsole/scripts/Runtime/AirConsole.cs +++ b/Assets/AirConsole/scripts/Runtime/AirConsole.cs @@ -658,6 +658,28 @@ public long GetServerTime() { return (long)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalMilliseconds + _server_time_offset; } + /// + /// Returns the platform capability configuration delivered in the ready event. + /// Use this to branch on device capabilities instead of platform or partner + /// names. Only available on the screen; controllers receive null. + /// Can only be called after OnReady. + /// + /// + /// A JToken containing: + /// supportedVideoFormats (string[]) - e.g. ["vp9","h264","vp8"] + /// transparentVideoSupported (bool) + /// unityVideoSupported (bool) + /// graphicsQualityTier (string) - "low", "medium", or "high" + /// Returns null if not yet received or on controllers. + /// + public JToken GetConfiguration() { + if (!IsAirConsoleUnityPluginReady()) { + throw new NotReadyException(); + } + + return _configuration; + } + /// /// Request that all devices return to the AirConsole store. /// @@ -1533,6 +1555,9 @@ private void OnReady(JObject msg) { _devices.Add(assign); } + // parse configuration + _configuration = msg["configuration"]; + _receivedReady = true; if (onReady != null) { @@ -1629,6 +1654,7 @@ private void ResetCaches(Action taskToQueueAfterClear) { _server_time_offset = 0; _location = null; _translations = null; + _configuration = null; _receivedReady = false; // Reset safe area @@ -1990,6 +2016,7 @@ internal static bool IsAndroidOrEditor { private int _server_time_offset; private string _location; private Dictionary _translations; + private JToken _configuration; private readonly List _players = new(); private readonly Queue eventQueue = new(); private bool _safeAreaWasSet; diff --git a/Assets/AirConsole/scripts/Tests/EditMode/AirConsoleTests.cs b/Assets/AirConsole/scripts/Tests/EditMode/AirConsoleTests.cs index f1b3961..8980736 100644 --- a/Assets/AirConsole/scripts/Tests/EditMode/AirConsoleTests.cs +++ b/Assets/AirConsole/scripts/Tests/EditMode/AirConsoleTests.cs @@ -82,6 +82,141 @@ public IEnumerator SetSafeArea_WithInvalidMessage_ExceptionIsRaised() { yield return null; } + [UnityTest] + [Timeout(300)] + public IEnumerator GetConfiguration_AfterReady_ReturnsConfiguration() { + if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android) { + Assert.Inconclusive("This test requires an Android build target"); + } + + bool testIsDone = false; + JObject configuration = JObject.FromObject(new { + supportedVideoFormats = new[] { "vp9", "h264", "vp8" }, + transparentVideoSupported = true, + unityVideoSupported = true, + graphicsQualityTier = "high" + }); + JObject readyMessage = JObject.FromObject(new { + action = "ready", + code = "test123", + device_id = 0, + server_time_offset = 0, + location = "http://test.airconsole.com", + devices = new object[] { new { location = "http://test.airconsole.com" } }, + configuration + }); + target = new GameObject("Target").AddComponent(); + target.onReady += _ => { + JToken result = target.GetConfiguration(); + Assert.IsNotNull(result, "Configuration should not be null after ready"); + Assert.AreEqual("high", (string)result["graphicsQualityTier"]); + Assert.AreEqual(true, (bool)result["transparentVideoSupported"]); + Assert.AreEqual(true, (bool)result["unityVideoSupported"]); + var formats = result["supportedVideoFormats"].ToObject(); + Assert.AreEqual(new[] { "vp9", "h264", "vp8" }, formats); + testIsDone = true; + }; + target.Initialize(); + + target.SimulateReady(readyMessage); + target.Update(); + + while (!testIsDone) { + yield return null; + } + } + + [UnityTest] + [Timeout(300)] + public IEnumerator GetConfiguration_BeforeReady_ThrowsNotReadyException() { + if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android) { + Assert.Inconclusive("This test requires an Android build target"); + } + + target = new GameObject("Target").AddComponent(); + target.Initialize(); + + // GetConfiguration() must throw before the READY message has been received. + Assert.Throws(() => target.GetConfiguration()); + + yield return null; + } + + [UnityTest] + [Timeout(300)] + public IEnumerator GetConfiguration_AfterResetCaches_ReturnsNull() { + if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android) { + Assert.Inconclusive("This test requires an Android build target"); + } + + bool testIsDone = false; + JObject configuration = JObject.FromObject(new { + supportedVideoFormats = new[] { "vp9", "h264", "vp8" }, + transparentVideoSupported = true, + unityVideoSupported = true, + graphicsQualityTier = "high" + }); + JObject readyMessage = JObject.FromObject(new { + action = "ready", + code = "test123", + device_id = 0, + server_time_offset = 0, + location = "http://test.airconsole.com", + devices = new object[] { new { location = "http://test.airconsole.com" } }, + configuration + }); + target = new GameObject("Target").AddComponent(); + target.onReady += _ => { + Assert.IsNotNull(target.GetConfiguration(), "Should have config after ready"); + // Simulate a reconnect / reload that clears caches — the field must be null + // (not stale) before the subsequent ready message arrives. + target.SimulateResetCaches(); + Assert.Throws(() => target.GetConfiguration()); + testIsDone = true; + }; + target.Initialize(); + + target.SimulateReady(readyMessage); + target.Update(); + + while (!testIsDone) { + yield return null; + } + } + + [UnityTest] + [Timeout(300)] + public IEnumerator GetConfiguration_WhenReadyDataLacksConfiguration_ReturnsNull() { + if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android) { + Assert.Inconclusive("This test requires an Android build target"); + } + + bool testIsDone = false; + // Ready message without a "configuration" key — server may omit the field. + JObject readyMessage = JObject.FromObject(new { + action = "ready", + code = "test123", + device_id = 0, + server_time_offset = 0, + location = "http://test.airconsole.com", + devices = new object[] { new { location = "http://test.airconsole.com" } } + }); + target = new GameObject("Target").AddComponent(); + target.onReady += _ => { + JToken result = target.GetConfiguration(); + Assert.IsNull(result, "Configuration should be null when not present in ready data"); + testIsDone = true; + }; + target.Initialize(); + + target.SimulateReady(readyMessage); + target.Update(); + + while (!testIsDone) { + yield return null; + } + } + public class AirConsoleTestRunner : AirConsole, IMonoBehaviourTest { private int frameCount; @@ -111,6 +246,21 @@ private void Start() { base.SetSafeArea(message); } + internal void SimulateReady(JObject message) { + // Use reflection to invoke the private OnReady method + var method = typeof(AirConsole).GetMethod("OnReady", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method.Invoke(this, new object[] { message }); + } + + internal void SimulateResetCaches() { + // Use reflection to invoke the private ResetCaches method. + // Passes a no-op action because we do not need the post-clear callback in tests. + var method = typeof(AirConsole).GetMethod("ResetCaches", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method.Invoke(this, new object[] { (System.Action)(() => { }) }); + } + internal void Initialize() { Awake(); Start();