using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using System.Xml; using UnityEngine; using UnityEngine.Rendering; using UnityEditor.Android; using UnityEditor.Build; using UnityEditor.Build.Reporting; using Unity.XR.Oculus; using UnityEditor; using UnityEditor.XR.Management; using UnityEngine.XR.Management; namespace UnityEditor.XR.Oculus { public class OculusBuildProcessor : XRBuildHelper { public override string BuildSettingsKey { get {return "Unity.XR.Oculus.Settings";} } private static List s_ValidStandaloneBuildTargets = new List() { BuildTarget.StandaloneWindows64 }; private bool IsCurrentBuildTargetVaild(BuildReport report) { return report.summary.platformGroup == BuildTargetGroup.Android || (report.summary.platformGroup == BuildTargetGroup.Standalone && s_ValidStandaloneBuildTargets.Contains(report.summary.platform)); } private bool HasLoaderEnabledForTarget(BuildTargetGroup buildTargetGroup) { if (buildTargetGroup != BuildTargetGroup.Standalone && buildTargetGroup != BuildTargetGroup.Android) return false; XRGeneralSettings settings = XRGeneralSettingsPerBuildTarget.XRGeneralSettingsForBuildTarget(buildTargetGroup); if (settings == null) return false; bool loaderFound = false; for (int i = 0; i < settings.Manager.activeLoaders.Count; ++i) { if (settings.Manager.activeLoaders[i] as OculusLoader != null) { loaderFound = true; break; } } return loaderFound; } private readonly string spatializerPluginName = "AudioPluginOculusSpatializer"; private readonly string spatializerReadableName = "OculusSpatializer"; private readonly string[] runtimePluginNames = new string[] { "OculusXRPlugin.dll", "OVRPlugin.dll", "libOculusXRPlugin.so", "OVRPlugin.aar" }; private bool ShouldIncludeRuntimePluginsInBuild(string path, BuildTargetGroup platformGroup) { return HasLoaderEnabledForTarget(platformGroup); } private bool ShouldIncludeSpatializerPluginsInBuild(string path) { string currentSpatializerPluginName = AudioSettings.GetSpatializerPluginName(); if (string.Compare(spatializerReadableName, currentSpatializerPluginName, true) == 0) return true; return false; } /// OnPreprocessBuild override to provide XR Plugin specific build actions. /// The build report. public override void OnPreprocessBuild(BuildReport report) { if (IsCurrentBuildTargetVaild(report) && HasLoaderEnabledForTarget(report.summary.platformGroup)) base.OnPreprocessBuild(report); var allPlugins = PluginImporter.GetAllImporters(); foreach (var plugin in allPlugins) { if (plugin.isNativePlugin) { foreach (var pluginName in runtimePluginNames) { if (plugin.assetPath.Contains(pluginName)) { plugin.SetIncludeInBuildDelegate((path) => { return ShouldIncludeRuntimePluginsInBuild(path, report.summary.platformGroup); }); break; } } // exlude spatializer related plugins if OculusSpatializer not selected under Audio setting if (plugin.assetPath.Contains(spatializerPluginName)) { plugin.SetIncludeInBuildDelegate(ShouldIncludeSpatializerPluginsInBuild); } } } } } public static class OculusBuildTools { public static bool OculusLoaderPresentInSettingsForBuildTarget(BuildTargetGroup btg) { var generalSettingsForBuildTarget = XRGeneralSettingsPerBuildTarget.XRGeneralSettingsForBuildTarget(btg); if (!generalSettingsForBuildTarget) return false; var settings = generalSettingsForBuildTarget.AssignedSettings; if (!settings) return false; bool loaderFound = false; for (int i = 0; i < settings.activeLoaders.Count; ++i) { if (settings.activeLoaders[i] as OculusLoader != null) { loaderFound = true; break; } } return loaderFound; } public static OculusSettings GetSettings() { OculusSettings settings = null; #if UNITY_EDITOR UnityEditor.EditorBuildSettings.TryGetConfigObject("Unity.XR.Oculus.Settings", out settings); #else settings = OculusSettings.s_Settings; #endif return settings; } } [InitializeOnLoad] public static class OculusEnterPlayModeSettingsCheck { static OculusEnterPlayModeSettingsCheck() { EditorApplication.playModeStateChanged += PlaymodeStateChangedEvent; } private static void PlaymodeStateChangedEvent(PlayModeStateChange state) { if (state == PlayModeStateChange.EnteredPlayMode) { if (!OculusBuildTools.OculusLoaderPresentInSettingsForBuildTarget(BuildTargetGroup.Standalone)) { return; } if (PlayerSettings.GetGraphicsAPIs(BuildTarget.StandaloneWindows)[0] != GraphicsDeviceType.Direct3D11) { Debug.LogError("D3D11 is currently the only graphics API compatible with the Oculus XR Plugin on desktop platforms. Please change the preferred Graphics API setting in Player Settings."); } } } } internal class OculusBuildHooks : IPreprocessBuildWithReport, IPostprocessBuildWithReport { public int callbackOrder { get; } internal static string s_AndroidManifestPath = ""; private static readonly Dictionary AndroidBootConfigVars = new Dictionary() { { "xr-meta-enabled", "1" } }; public void OnPreprocessBuild(BuildReport report) { if(!OculusBuildTools.OculusLoaderPresentInSettingsForBuildTarget(report.summary.platformGroup)) return; var settings = OculusBuildTools.GetSettings(); if (report.summary.platformGroup == BuildTargetGroup.Android) { GraphicsDeviceType firstGfxType = PlayerSettings.GetGraphicsAPIs(report.summary.platform)[0]; #if UNITY_2023_1_OR_NEWER if (firstGfxType != GraphicsDeviceType.OpenGLES3 && firstGfxType != GraphicsDeviceType.Vulkan) { throw new BuildFailedException("OpenGLES3 and Vulkan are currently the only graphics APIs compatible with the Oculus XR Plugin on mobile platforms."); } if (PlayerSettings.colorSpace != ColorSpace.Linear && (firstGfxType == GraphicsDeviceType.OpenGLES3)) { throw new BuildFailedException("Only Linear Color Space is supported when using OpenGLES. Please set Color Space to Linear in Player Settings, or switch to Vulkan."); } #else if (firstGfxType != GraphicsDeviceType.OpenGLES3 && firstGfxType != GraphicsDeviceType.Vulkan && firstGfxType != GraphicsDeviceType.OpenGLES2) { throw new BuildFailedException("OpenGLES2, OpenGLES3, and Vulkan are currently the only graphics APIs compatible with the Oculus XR Plugin on mobile platforms."); } if (PlayerSettings.colorSpace != ColorSpace.Linear && (firstGfxType == GraphicsDeviceType.OpenGLES3 || firstGfxType == GraphicsDeviceType.OpenGLES2)) { throw new BuildFailedException("Only Linear Color Space is supported when using OpenGLES. Please set Color Space to Linear in Player Settings, or switch to Vulkan."); } #endif if (PlayerSettings.Android.minSdkVersion < AndroidSdkVersions.AndroidApiLevel23) { throw new BuildFailedException("Android Minimum API Level must be set to 23 or higher for the Oculus XR Plugin."); } // some features don't work in non-ARM64 builds if ((PlayerSettings.Android.targetArchitectures & AndroidArchitecture.ARM64) != AndroidArchitecture.ARM64) { // ETFR requires ARM64 if ((settings != null) && (settings.FoveatedRenderingMethod == OculusSettings.FoveationMethod.EyeTrackedFoveatedRendering)) { throw new BuildFailedException("Eye Tracked Foveated Rendering can only be enabled when ARM64 is selected as the Target Architecture."); } } // write Android Meta tags to bootconfig var bootConfig = new BootConfig(report); bootConfig.ReadBootConfig(); foreach (var entry in AndroidBootConfigVars) { bootConfig.SetValueForKey(entry.Key, entry.Value); } bootConfig.WriteBootConfig(); } if (report.summary.platform == BuildTarget.StandaloneWindows) { throw new BuildFailedException("The Oculus XR Plugin doesn't support 32-bit Windows player builds. Please use 64-bit instead."); } if (report.summary.platform == BuildTarget.StandaloneWindows || report.summary.platform == BuildTarget.StandaloneWindows64) { if (PlayerSettings.GetGraphicsAPIs(report.summary.platform)[0] != GraphicsDeviceType.Direct3D11) { throw new BuildFailedException("D3D11 is currently the only graphics API compatible with the Oculus XR Plugin on desktop platforms. Please change the Graphics API setting in Player Settings."); } } } public void OnPostprocessBuild(BuildReport report) { if(!OculusBuildTools.OculusLoaderPresentInSettingsForBuildTarget(report.summary.platformGroup)) return; if (report.summary.platformGroup == BuildTargetGroup.Android) { // clean up Android Meta boot settings after build BootConfig bootConfig = new BootConfig(report); bootConfig.ReadBootConfig(); foreach (KeyValuePair entry in AndroidBootConfigVars) { bootConfig.ClearEntryForKeyAndValue(entry.Key, entry.Value); } bootConfig.WriteBootConfig(); // clean up Android manifest after build if (!string.IsNullOrEmpty(s_AndroidManifestPath)) { try { File.Delete(s_AndroidManifestPath); } catch (Exception e) { // this only fails if the file can't be deleted; it is quiet if the file does not exist Debug.LogWarning("Failed to clean up AndroidManifest.xml file located at " + s_AndroidManifestPath + " : " + e.ToString()); } } s_AndroidManifestPath = ""; // verify settings var settings = OculusBuildTools.GetSettings(); if (settings != null) { GraphicsDeviceType firstGfxType = PlayerSettings.GetGraphicsAPIs(report.summary.platform)[0]; if (settings.SymmetricProjection && (!(settings.TargetQuest2 || settings.TargetQuestPro) || settings.m_StereoRenderingModeAndroid != OculusSettings.StereoRenderingModeAndroid.Multiview || firstGfxType != GraphicsDeviceType.Vulkan)) { Debug.LogWarning("Symmetric Projection is only supported on Quest 2 and Quest Pro with Vulkan and Multiview."); } if (settings.SubsampledLayout && (!(settings.TargetQuest2 || settings.TargetQuestPro) || firstGfxType != GraphicsDeviceType.Vulkan)) { Debug.LogWarning("Subsampled Layout is only supported on Quest 2 and Quest Pro with Vulkan."); } if (settings.DepthSubmission && (settings.m_StereoRenderingModeAndroid != OculusSettings.StereoRenderingModeAndroid.Multiview || firstGfxType != GraphicsDeviceType.Vulkan)) { Debug.LogWarning("Depth Submission is only supported on Vulkan with Multiview."); } if (settings.DepthSubmission) { Debug.LogWarning("Enabling Depth Submission may cause a crash on application startup if MSAA is not enabled. This will be resolved in future versions of Unity."); } } } if (EditorUserBuildSettings.waitForManagedDebugger && report.summary.platformGroup == BuildTargetGroup.Android && ((report.summary.options & BuildOptions.AutoRunPlayer) != 0)) Debug.Log("[Wait For Managed Debugger To Attach] Use volume Up or Down button on headset to confirm ...\n"); } } /// /// Small utility class for reading, updating and writing boot config. /// internal class BootConfig { private const string XrBootSettingsKey = "xr-boot-settings"; private readonly Dictionary bootConfigSettings; private readonly string buildTargetName; public BootConfig(BuildReport report) { bootConfigSettings = new Dictionary(); buildTargetName = BuildPipeline.GetBuildTargetName(report.summary.platform); } public void ReadBootConfig() { bootConfigSettings.Clear(); string xrBootSettings = EditorUserBuildSettings.GetPlatformSettings(buildTargetName, XrBootSettingsKey); if (!string.IsNullOrEmpty(xrBootSettings)) { // boot settings string format // :[;:]* var bootSettings = xrBootSettings.Split(';'); foreach (var bootSetting in bootSettings) { var setting = bootSetting.Split(':'); if (setting.Length == 2 && !string.IsNullOrEmpty(setting[0]) && !string.IsNullOrEmpty(setting[1])) { bootConfigSettings.Add(setting[0], setting[1]); } } } } public void SetValueForKey(string key, string value) => bootConfigSettings[key] = value; public bool TryGetValue(string key, out string value) => bootConfigSettings.TryGetValue(key, out value); public void ClearEntryForKeyAndValue(string key, string value) { if (bootConfigSettings.TryGetValue(key, out string dictValue) && dictValue == value) { bootConfigSettings.Remove(key); } } public void WriteBootConfig() { // boot settings string format // :[;:]* bool firstEntry = true; var sb = new System.Text.StringBuilder(); foreach (var kvp in bootConfigSettings) { if (!firstEntry) { sb.Append(";"); } sb.Append($"{kvp.Key}:{kvp.Value}"); firstEntry = false; } EditorUserBuildSettings.SetPlatformSettings(buildTargetName, XrBootSettingsKey, sb.ToString()); } } #if UNITY_ANDROID internal class OculusManifest : IPostGenerateGradleAndroidProject { static readonly string k_AndroidURI = "http://schemas.android.com/apk/res/android"; static readonly string k_AndroidManifestPath = "/src/main/AndroidManifest.xml"; static readonly string k_AndroidProGuardPath = "/proguard-unity.txt"; static readonly string k_OculusProGuardRule = Environment.NewLine + "-keep class com.unity.oculus.OculusUnity { *; }" + Environment.NewLine; void UpdateOrCreateAttributeInTag(XmlDocument doc, string parentPath, string tag, string name, string value) { var xmlNode = doc.SelectSingleNode(parentPath + "/" + tag); if (xmlNode != null) { ((XmlElement)xmlNode).SetAttribute(name, k_AndroidURI, value); } } void UpdateOrCreateNameValueElementsInTag(XmlDocument doc, string parentPath, string tag, string firstName, string firstValue, string secondName, string secondValue) { var xmlNodeList = doc.SelectNodes(parentPath + "/" + tag); foreach (XmlNode node in xmlNodeList) { var attributeList = ((XmlElement)node).Attributes; foreach (XmlAttribute attrib in attributeList) { if (attrib.Value == firstValue) { XmlAttribute valueAttrib = attributeList[secondName, k_AndroidURI]; if (valueAttrib != null) { valueAttrib.Value = secondValue; } else { ((XmlElement)node).SetAttribute(secondName, k_AndroidURI, secondValue); } return; } } } // Didn't find any attributes that matched, create both (or all three) XmlElement childElement = doc.CreateElement(tag); childElement.SetAttribute(firstName, k_AndroidURI, firstValue); childElement.SetAttribute(secondName, k_AndroidURI, secondValue); var xmlParentNode = doc.SelectSingleNode(parentPath); if (xmlParentNode != null) { xmlParentNode.AppendChild(childElement); } } // same as above, but don't create if the node already exists void CreateNameValueElementsInTag(XmlDocument doc, string parentPath, string tag, string firstName, string firstValue, string secondName=null, string secondValue=null, string thirdName=null, string thirdValue=null) { var xmlNodeList = doc.SelectNodes(parentPath + "/" + tag); // don't create if the firstValue matches foreach (XmlNode node in xmlNodeList) { foreach (XmlAttribute attrib in node.Attributes) { if (attrib.Value == firstValue) { return; } } } XmlElement childElement = doc.CreateElement(tag); childElement.SetAttribute(firstName, k_AndroidURI, firstValue); if (secondValue != null) { childElement.SetAttribute(secondName, k_AndroidURI, secondValue); } if (thirdValue != null) { childElement.SetAttribute(thirdName, k_AndroidURI, thirdValue); } var xmlParentNode = doc.SelectSingleNode(parentPath); if (xmlParentNode != null) { xmlParentNode.AppendChild(childElement); } } void RemoveNameValueElementInTag(XmlDocument doc, string parentPath, string tag, string name, string value) { var xmlNodeList = doc.SelectNodes(parentPath + "/" + tag); foreach (XmlNode node in xmlNodeList) { var attributeList = ((XmlElement)node).Attributes; foreach (XmlAttribute attrib in attributeList) { if (attrib.Name == name && attrib.Value == value) { node.ParentNode?.RemoveChild(node); } } } } // disable ProGuard on Oculus Java files void AddProGuardRule(string path) { try { var proguardPath = path + k_AndroidProGuardPath; if (File.Exists(proguardPath)) { File.AppendAllText(proguardPath, k_OculusProGuardRule); } } catch (Exception e) { Debug.LogWarning("Failed to append Oculus rule to ProGuard file: " + e.ToString()); } } public void OnPostGenerateGradleAndroidProject(string path) { if(!OculusBuildTools.OculusLoaderPresentInSettingsForBuildTarget(BuildTargetGroup.Android)) return; AddProGuardRule(path); var manifestPath = path + k_AndroidManifestPath; var manifestDoc = new XmlDocument(); manifestDoc.Load(manifestPath); var sdkVersion = (int)PlayerSettings.Android.minSdkVersion; UpdateOrCreateAttributeInTag(manifestDoc, "/", "manifest", "installLocation", "auto"); var nodePath = "/manifest/application"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.samsung.android.vr.application.mode", "value", "vr_only"); var settings = OculusBuildTools.GetSettings(); var lowOverheadModeVal = ((settings != null) && settings.LowOverheadMode) ? "true" : "false"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.unity.xr.oculus.LowOverheadMode", "value", lowOverheadModeVal); var lateLatchingVal = ((settings != null) && settings.LateLatching) ? "true" : "false"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.unity.xr.oculus.LateLatching", "value", lateLatchingVal); var lateLatchingDebugVal = ((settings != null) && settings.LateLatchingDebug) ? "true" : "false"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.unity.xr.oculus.LateLatchingDebug", "value", lateLatchingDebugVal); nodePath = "/manifest/application"; UpdateOrCreateAttributeInTag(manifestDoc, nodePath, "activity", "screenOrientation", "landscape"); UpdateOrCreateAttributeInTag(manifestDoc, nodePath, "activity", "theme", "@android:style/Theme.Black.NoTitleBar.Fullscreen"); var configChangesValue = "keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|uiMode"; configChangesValue = ((sdkVersion >= 24) ? configChangesValue + "|density" : configChangesValue); UpdateOrCreateAttributeInTag(manifestDoc, nodePath, "activity", "configChanges", configChangesValue); if (sdkVersion >= 24) { UpdateOrCreateAttributeInTag(manifestDoc, nodePath, "activity", "resizeableActivity", "false"); } UpdateOrCreateAttributeInTag(manifestDoc, nodePath, "activity", "launchMode", "singleTask"); nodePath = "/manifest"; CreateNameValueElementsInTag(manifestDoc, nodePath, "uses-feature", "name", "android.hardware.vr.headtracking", "required", "true", "version", "1"); var eyeTrackedFoveatedRendering = ((settings != null) && (settings.FoveatedRenderingMethod == OculusSettings.FoveationMethod.EyeTrackedFoveatedRendering)); if (eyeTrackedFoveatedRendering) { CreateNameValueElementsInTag(manifestDoc, nodePath, "uses-feature", "name", "oculus.software.eye_tracking", "required", "false"); CreateNameValueElementsInTag(manifestDoc, nodePath, "uses-permission", "name", "com.oculus.permission.EYE_TRACKING"); } string supportedDevices = null; if (settings != null) { var deviceList = new List(); if (settings.TargetQuest2) deviceList.Add("quest2"); if (settings.TargetQuestPro) deviceList.Add("cambria"); if (deviceList.Count > 0) { StringBuilder sb = new StringBuilder(); string delim = ""; foreach (string device in deviceList) { sb.Append(delim); sb.Append(device); delim = "|"; } supportedDevices = sb.ToString(); } else { Debug.LogWarning("No target devices selected in Oculus Android project settings. No devices will be listed as supported in the application Android manifest."); } } else { supportedDevices = "quest2"; } if (supportedDevices != null) { nodePath = "/manifest/application"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.oculus.supportedDevices", "value", supportedDevices); } if (settings != null && settings.SystemSplashScreen != null) { string splashScreenAssetPath = AssetDatabase.GetAssetPath(settings.SystemSplashScreen); if (Path.GetExtension(splashScreenAssetPath).ToLower() != ".png") { throw new BuildFailedException("Invalid file format of System Splash Screen. It has to be a PNG file to be used by the Quest OS. The asset path: " + splashScreenAssetPath); } else { string sourcePath = splashScreenAssetPath; string targetFolder = Path.Combine(path, "src/main/assets"); string targetPath = targetFolder + "/vr_splash.png"; // copy the splash over into the gradle folder and make sure it's not read only FileUtil.ReplaceFile(sourcePath, targetPath); FileInfo targetInfo = new FileInfo(targetPath); targetInfo.IsReadOnly = false; } nodePath = "/manifest/application"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.oculus.ossplash", "value", "true"); } nodePath = "/manifest/application/activity"; UpdateOrCreateNameValueElementsInTag(manifestDoc, nodePath, "meta-data", "name", "com.oculus.vr.focusaware", "value", "true"); nodePath = "/manifest/application/activity/intent-filter"; CreateNameValueElementsInTag(manifestDoc, nodePath, "category", "name", "com.oculus.intent.category.VR"); // if the Microphone class is used in a project, the BLUETOOTH permission is automatically added to the manifest // we remove it here since it will cause projects to fail Oculus cert // this shouldn't affect Bluetooth HID devices, which don't need the permission nodePath = "/manifest"; RemoveNameValueElementInTag(manifestDoc, nodePath, "uses-permission", "android:name", "android.permission.BLUETOOTH"); manifestDoc.Save(manifestPath); // let OnPostprocessBuild() know which intermediate manifest to delete OculusBuildHooks.s_AndroidManifestPath = manifestPath; } public int callbackOrder { get { return 10000; } } void DebugPrint(XmlDocument doc) { var sw = new System.IO.StringWriter(); var xw = XmlWriter.Create(sw); doc.Save(xw); Debug.Log(sw); } } #endif }