/*=============================================================================== Copyright (C) 2024 Immersal - Part of Hexagon. All Rights Reserved. This file is part of the Immersal SDK. The Immersal SDK cannot be copied, distributed, or made available to third-parties for commercial purposes without written permission of Immersal Ltd. Contact sales@immersal.com for licensing requests. ===============================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Xml; using UnityEditor; using UnityEditor.Build; using UnityEditor.XR.Management; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.XR.Management; namespace Immersal { internal enum ProjectValidationState { Uninitialized = 0, Issues = 1, NoIssues = 2 } internal class ImmersalProjectValidationWindow : EditorWindow, IActiveBuildTargetChanged { private static ImmersalProjectValidationWindow window = null; private Vector2 m_ScrollViewPos = Vector2.zero; private static ProjectValidationState SavedValidationState { get => (ProjectValidationState)PlayerPrefs.GetInt(ImmersalProjectValidation.PlayerPrefsStateString, 0); set => PlayerPrefs.SetInt(ImmersalProjectValidation.PlayerPrefsStateString, (int)value); } [InitializeOnLoadMethod] internal static void InitializeOnLoad() { EditorApplication.delayCall += () => { if (SavedValidationState is ProjectValidationState.Uninitialized or ProjectValidationState.Issues) { SavedValidationState = ProjectValidationState.Issues; ShowWindow(); } }; } [MenuItem("Immersal SDK/Project Validation")] private static void MenuItem() { ShowWindow(); } private static void ShowWindow(BuildTargetGroup buildTargetGroup = BuildTargetGroup.Unknown) { if (window == null) { window = (ImmersalProjectValidationWindow) GetWindow(typeof(ImmersalProjectValidationWindow)); window.titleContent = Content.Title; window.minSize = new Vector2(500.0f, 300.0f); } window.UpdateIssues(); window.Show(); } private static void InitStyles() { if (Styles.s_ListLabel != null) return; Styles.s_ListLabel = new GUIStyle(Styles.s_SelectionStyle) { border = new RectOffset(0, 0, 0, 0), padding = new RectOffset(5, 5, 5, 5), margin = new RectOffset(5, 5, 5, 5) }; Styles.s_TargetPlatformLabel = new GUIStyle(EditorStyles.label) { fontSize = 9, fontStyle = FontStyle.Normal, padding = new RectOffset(10, 10, 0, 0), }; Styles.s_IssuesTitleLabel = new GUIStyle(EditorStyles.label) { fontSize = 16, fontStyle = FontStyle.Bold, padding = new RectOffset(10, 10, 0, 0), }; Styles.s_Wrap = new GUIStyle(EditorStyles.label) { wordWrap = true, alignment = TextAnchor.MiddleLeft, padding = new RectOffset(0, 5, 5, 5) }; Styles.s_Icon = new GUIStyle(EditorStyles.label) { margin = new RectOffset(10, 10, 8, 0), fixedWidth = Content.IconSize.x * 2 }; Styles.s_InfoBanner = new GUIStyle(EditorStyles.label) { padding = new RectOffset(10, 10, 15, 5) }; Styles.s_Fix = new GUIStyle(EditorStyles.miniButton) { stretchWidth = false, fixedWidth = 80, margin = new RectOffset(0, 0, 8, 5) }; Styles.s_FixAll = new GUIStyle(EditorStyles.miniButton) { stretchWidth = false, fixedWidth = 80, margin = new RectOffset(0, 10, 5, 8) }; } private readonly List projectIssues = new List(); private List fixAllIssues = new List(); private double lastUpdate; private const double updateInterval = 1.0; private const double backgroundUpdateInterval = 1.0; private static class Content { public static readonly GUIContent Title = new GUIContent("Immersal Project Validation", ""); public static readonly GUIContent WarningIcon = EditorGUIUtility.IconContent("Warning@2x"); public static readonly GUIContent ErrorIcon = EditorGUIUtility.IconContent("Error@2x"); public static readonly GUIContent FixButton = new GUIContent("Fix", ""); public static readonly GUIContent PlayMode = new GUIContent("Exit play mode", EditorGUIUtility.IconContent("console.infoicon").image); public static readonly Vector2 IconSize = new Vector2(16.0f, 16.0f); } private static class Styles { public static GUIStyle s_SelectionStyle = "TV Selection"; public static GUIStyle s_IssuesBackground = "ScrollViewAlt"; public static GUIStyle s_ListLabel; public static GUIStyle s_TargetPlatformLabel; public static GUIStyle s_IssuesTitleLabel; public static GUIStyle s_Wrap; public static GUIStyle s_Icon; public static GUIStyle s_InfoBanner; public static GUIStyle s_Fix; public static GUIStyle s_FixAll; } protected void OnFocus() => UpdateIssues(true); protected void Update() => UpdateIssues(); private void UpdateIssues(bool force = false) { var interval = EditorWindow.focusedWindow == this ? updateInterval : backgroundUpdateInterval; if (!force && EditorApplication.timeSinceStartup - lastUpdate < interval) return; // Fix all foreach (ImmersalProjectIssue issue in fixAllIssues) { issue.Fix?.Invoke(); } fixAllIssues.Clear(); ImmersalProjectValidation.CheckIssues(projectIssues); Repaint(); if (projectIssues.Count > 0) { if(SavedValidationState != ProjectValidationState.Uninitialized) SavedValidationState = ProjectValidationState.Issues; } else { SavedValidationState = ProjectValidationState.NoIssues; } lastUpdate = EditorApplication.timeSinceStartup; } public void OnGUI() { InitStyles(); EditorGUIUtility.SetIconSize(Content.IconSize); using (new EditorGUI.DisabledScope(fixAllIssues.Count > 0)) { EditorGUILayout.BeginVertical(); if (EditorApplication.isPlaying && projectIssues.Count > 0) { GUILayout.Label(Content.PlayMode, Styles.s_InfoBanner); } EditorGUILayout.Space(); bool fixableIssues = projectIssues.Any(f => f.Fix != null); EditorGUILayout.LabelField($"Target platform: {ImmersalProjectValidation.ActiveBuildTarget.ToString()}", Styles.s_TargetPlatformLabel); EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(EditorApplication.isPlaying)) { EditorGUILayout.LabelField($"Project issues ({projectIssues.Count}):", Styles.s_IssuesTitleLabel); } if (fixableIssues) { using (new EditorGUI.DisabledScope(EditorApplication.isPlaying)) { if (GUILayout.Button("Fix All", Styles.s_FixAll)) { fixAllIssues = new List(); foreach (ImmersalProjectIssue issue in projectIssues) { if (!issue.RequiresManualFix) fixAllIssues.Add(issue); } } } } EditorGUILayout.EndHorizontal(); m_ScrollViewPos = EditorGUILayout.BeginScrollView(m_ScrollViewPos, Styles.s_IssuesBackground, GUILayout.ExpandHeight(true)); using (new EditorGUI.DisabledScope(EditorApplication.isPlaying)) { foreach (ImmersalProjectIssue issue in projectIssues) { EditorGUILayout.BeginHorizontal(Styles.s_ListLabel); GUILayout.Label(issue.Error ? Content.ErrorIcon : Content.WarningIcon, Styles.s_Icon, GUILayout.Width(Content.IconSize.x)); GUILayout.Label(issue.Message(), Styles.s_Wrap); GUILayout.FlexibleSpace(); GUILayout.Label("", GUILayout.Width(Content.IconSize.x * 1.5f)); if (issue.Fix != null) { if (GUILayout.Button(Content.FixButton, Styles.s_Fix)) { issue.Fix(); } } else if (fixableIssues) { GUILayout.Label("", GUILayout.Width(80.0f)); } EditorGUILayout.EndHorizontal(); } } EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); } } public void OnActiveBuildTargetChanged(BuildTarget previousTarget, BuildTarget newTarget) { UpdateIssues(true); } public int callbackOrder => 0; } #if UNITY_EDITOR public interface IImmersalProjectIssueProvider { public string Name { get; } public bool Enabled { get; set; } public bool DisableDefaultIssues { get; } public IEnumerable Issues { get; } } public class ImmersalProjectIssue { public Func Message; public Func Check; public Action Fix; public bool Error; public bool RequiresManualFix; } public static class ImmersalProjectValidation { public static string PlayerPrefsStateString = "ImmersalProjectValidationState"; private static IImmersalProjectIssueProvider m_DefaultIssueProvider = new DefaultImmersalProjectIssueProvider(); private static Dictionary m_IssueProviders = new Dictionary(); public static BuildTargetGroup ActiveBuildTargetGroup = ActiveBuildTarget switch { BuildTarget.iOS => BuildTargetGroup.iOS, BuildTarget.Android => BuildTargetGroup.Android, BuildTarget.StandaloneOSX => BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows => BuildTargetGroup.Standalone, BuildTarget.StandaloneWindows64 => BuildTargetGroup.Standalone, BuildTarget.StandaloneLinux64 => BuildTargetGroup.Standalone, BuildTarget.WSAPlayer => BuildTargetGroup.WSA, _ => BuildTargetGroup.Unknown }; public static BuildTarget ActiveBuildTarget => EditorUserBuildSettings.activeBuildTarget; public static void RegisterIssueProvider(IImmersalProjectIssueProvider provider) { m_IssueProviders[provider.Name] = provider; } public static void CheckIssues(List issues) { issues.Clear(); if (m_IssueProviders.Count > 1) { issues.Add(new ImmersalProjectIssue { Check = () => false, Message = () => "Multiple packages affecting project issues.", Error = false }); } foreach (IImmersalProjectIssueProvider provider in m_IssueProviders.Values) { if (provider.DisableDefaultIssues) m_DefaultIssueProvider.Enabled = false; if (provider.Enabled) issues.AddRange(provider.Issues); } if (m_DefaultIssueProvider.Enabled) issues.AddRange(m_DefaultIssueProvider.Issues); issues.RemoveAll(issue => issue.Check?.Invoke() ?? false); } } internal class DefaultImmersalProjectIssueProvider : IImmersalProjectIssueProvider { public string Name => "Default"; public bool DisableDefaultIssues => false; public bool Enabled { get; set; } = true; public IEnumerable Issues => DefaultProjectIssues; // ReSharper disable once HeapView.ObjectAllocation private static readonly ImmersalProjectIssue[] DefaultProjectIssues = { // Graphics API new ImmersalProjectIssue() { Message = () => { return ImmersalProjectValidation.ActiveBuildTarget switch { BuildTarget.iOS => "Graphics API must be set to Metal", BuildTarget.Android => "Graphics API must be set to OpenGLES3", BuildTarget.StandaloneWindows => "Graphics API must set to OpenGLCore", _ => "Unsupported GraphicsAPI for current build target." }; }, Check = () => { GraphicsDeviceType[] graphicAPIs = PlayerSettings.GetGraphicsAPIs(ImmersalProjectValidation.ActiveBuildTarget); return ImmersalProjectValidation.ActiveBuildTarget switch { BuildTarget.iOS => graphicAPIs.Length == 1 && graphicAPIs[0] == GraphicsDeviceType.Metal, BuildTarget.Android => graphicAPIs.Length == 1 && graphicAPIs[0] == GraphicsDeviceType.OpenGLES3, BuildTarget.StandaloneWindows => graphicAPIs.Length == 1 && graphicAPIs[0] == GraphicsDeviceType.OpenGLCore, _ => true }; }, Fix = () => { GraphicsDeviceType[] cga = PlayerSettings.GetGraphicsAPIs(ImmersalProjectValidation.ActiveBuildTarget); var autoGraphicAPI = PlayerSettings.GetUseDefaultGraphicsAPIs(ImmersalProjectValidation.ActiveBuildTarget); if (autoGraphicAPI) PlayerSettings.SetUseDefaultGraphicsAPIs(ImmersalProjectValidation.ActiveBuildTarget, false); GraphicsDeviceType[] graphicAPIs = ImmersalProjectValidation.ActiveBuildTarget switch { BuildTarget.iOS => new GraphicsDeviceType[] { GraphicsDeviceType.Metal }, BuildTarget.Android => new GraphicsDeviceType[] { GraphicsDeviceType.OpenGLES3 }, BuildTarget.StandaloneWindows => new GraphicsDeviceType[] { GraphicsDeviceType.OpenGLCore }, _ => PlayerSettings.GetGraphicsAPIs(ImmersalProjectValidation.ActiveBuildTarget) }; PlayerSettings.SetGraphicsAPIs(ImmersalProjectValidation.ActiveBuildTarget, graphicAPIs); }, Error = true, }, // IL2CPP new ImmersalProjectIssue() { Message = () => "IL2CPP must be enabled.", Check = () => PlayerSettings.GetScriptingBackend(ImmersalProjectValidation.ActiveBuildTargetGroup) == ScriptingImplementation.IL2CPP, Fix = () => { PlayerSettings.SetScriptingBackend(ImmersalProjectValidation.ActiveBuildTargetGroup, ScriptingImplementation.IL2CPP); }, Error = true, }, // Allow unsafe code new ImmersalProjectIssue() { Message = () => "Allow 'unsafe' code must be enabled.", Check = () => PlayerSettings.allowUnsafeCode, Fix = () => { PlayerSettings.allowUnsafeCode = true; }, Error = true, }, // Camera usage description new ImmersalProjectIssue() { Message = () => "Camera Usage Description must be defined.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.iOS || PlayerSettings.iOS.cameraUsageDescription != "", Fix = () => { PlayerSettings.iOS.cameraUsageDescription = "Required for augmented reality support."; }, Error = true, }, // Location usage description new ImmersalProjectIssue() { Message = () => "Location Usage Description must be defined.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.iOS || PlayerSettings.iOS.locationUsageDescription != "", Fix = () => { PlayerSettings.iOS.locationUsageDescription = "Required for satellite positioning support."; }, Error = true, }, // minimum ios version 12.0 new ImmersalProjectIssue() { Message = () => "Target minimum iOS Version must be 12.0 or higher.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.iOS || (float.TryParse(PlayerSettings.iOS.targetOSVersionString, out float minVersion) && minVersion >= 12.0f), Fix = () => { PlayerSettings.iOS.targetOSVersionString = "12.0"; }, Error = true, }, // minimum android api version 26 new ImmersalProjectIssue() { Message = () => "Minimum Android API Level must be 26 or higher.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.Android || PlayerSettings.Android.minSdkVersion >= AndroidSdkVersions.AndroidApiLevel26, Fix = () => { PlayerSettings.Android.minSdkVersion = AndroidSdkVersions.AndroidApiLevel26; }, Error = true, }, // ARM64 new ImmersalProjectIssue() { Message = () => "ARM64 Target Architecture must be enabled.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.Android || (PlayerSettings.Android.targetArchitectures & AndroidArchitecture.ARM64) != 0, Fix = () => { PlayerSettings.Android.targetArchitectures |= AndroidArchitecture.ARM64; }, Error = true, }, // ARKit loader new ImmersalProjectIssue() { Message = () => "ARKit XR-Plugin Provider must be enabled.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.iOS || IsPluginLoaderEnabled("AR Kit Loader") , Fix = () => { EditorUserBuildSettings.selectedBuildTargetGroup = ImmersalProjectValidation.ActiveBuildTargetGroup; SettingsService.OpenProjectSettings("Project/XR Plug-in Management"); }, Error = true, RequiresManualFix = true }, // ARCore loader new ImmersalProjectIssue() { Message = () => "ARCore XR-Plugin Provider must be enabled.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.Android || IsPluginLoaderEnabled("AR Core Loader"), Fix = () => { EditorUserBuildSettings.selectedBuildTargetGroup = ImmersalProjectValidation.ActiveBuildTargetGroup; SettingsService.OpenProjectSettings("Project/XR Plug-in Management"); }, Error = true, RequiresManualFix = true }, new ImmersalProjectIssue() { Message = () => "Universal Render Pipeline is recommended", Check = () => GraphicsSettings.defaultRenderPipeline != null, Fix = SetupRenderPipeline, Error = false, RequiresManualFix = false }, // android manifest exists new ImmersalProjectIssue() { Message = () => "Custom Android Manifest is required.", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.Android || CheckManifestExists(), Fix = CreateManifest, Error = true, }, // android manifest has necessary attributes new ImmersalProjectIssue() { Message = () => "Android Manifest should include network permissions", Check = () => ImmersalProjectValidation.ActiveBuildTarget != BuildTarget.Android || CheckManifestContent(), Fix = ConfigureManifest, Error = false, }, }; private static bool IsPluginLoaderEnabled(string loaderName) { XRGeneralSettings generalSettings = XRGeneralSettingsPerBuildTarget.XRGeneralSettingsForBuildTarget(ImmersalProjectValidation.ActiveBuildTargetGroup); if (generalSettings == null) return false; XRManagerSettings managerSettings = generalSettings.AssignedSettings; string loaderNameNoWhitespace = loaderName.Replace(" ", ""); return managerSettings != null && managerSettings.activeLoaders.Any(loader => (loader.name == loaderName || loader.name == loaderNameNoWhitespace)); } private static void SetupRenderPipeline() { string destinationPath = Application.dataPath; // Renderer and RenderPipelineAsset if (CopyFromPackage("Editor/ImmersalURPAsset_Renderer.asset", Path.Combine(destinationPath, "ImmersalURPAsset_Renderer.asset"), true)) { // Load RendererData string rendererDataPath = Path.Combine("Assets", "ImmersalURPAsset_Renderer.asset"); UniversalRendererData rendererData = (UniversalRendererData)AssetDatabase.LoadAssetAtPath(rendererDataPath, typeof(UniversalRendererData)); if (rendererData != null) { // Create / load RenderPipelineAsset UniversalRenderPipelineAsset rpa = UniversalRenderPipelineAsset.Create(rendererData); // Save to file string rpaSavePath = Path.Combine("Assets", "ImmersalURPAsset.asset"); AssetDatabase.CreateAsset(rpa, rpaSavePath); // Configure ConfigureRenderPipelineAsset(rpa); // Set in settings GraphicsSettings.defaultRenderPipeline = rpa; QualitySettings.renderPipeline = rpa; } } // Global Settings if (CopyFromPackage("Editor/ImmersalURPGlobalSettings.asset", Path.Combine(destinationPath, "ImmersalURPGlobalSettings.asset"), true)) { string settingsPath = Path.Combine("Assets", "ImmersalURPGlobalSettings.asset"); RenderPipelineGlobalSettings settings = (RenderPipelineGlobalSettings)AssetDatabase.LoadAssetAtPath(settingsPath, typeof(RenderPipelineGlobalSettings)); if (settings != null) { GraphicsSettings.RegisterRenderPipelineSettings(settings); } } } private static void ConfigureRenderPipelineAsset(UniversalRenderPipelineAsset asset) { asset.supportsHDR = false; asset.cascadeBorder = 0.1f; } private static bool CopyFromPackage(string packageRelativePath, string destinationPath, bool refreshDatabase = true) { string sourcePath = Path.Combine("Packages/com.immersal.core/", packageRelativePath); string absolutePath = FixWindowsPath(Path.GetFullPath(sourcePath)); destinationPath = FixWindowsPath(destinationPath); if (File.Exists(absolutePath)) { if (File.Exists(destinationPath)) { ImmersalLogger.LogWarning( $"Attempting to copy file from Immersal package but destination already exists: {destinationPath}"); } else { // Have to manually create directories or CopyFileOrDirectory will error on Windows string dirName = Path.GetDirectoryName(destinationPath); if (dirName == null) return false; if (!Directory.Exists(dirName)) Directory.CreateDirectory(dirName); FileUtil.CopyFileOrDirectory(absolutePath, destinationPath); } if (refreshDatabase) AssetDatabase.Refresh(); return true; } ImmersalLogger.LogWarning($"Could not locate {packageRelativePath} in Immersal package."); return false; } // Unity API expects forward-slashes everywhere, but .NET methods produce paths with back-slashes on Windows private static string FixWindowsPath(string path) { return path.Replace("\\", "/"); } private static bool CheckManifestExists() { return File.Exists(GetManifestPath()); } private static bool CheckManifestContent() { if (CheckManifestExists()) { AndroidManifest manifest = new AndroidManifest(GetManifestPath()); bool internet = manifest.CheckPermission("android.permission.INTERNET"); bool network = manifest.CheckPermission("android.permission.ACCESS_NETWORK_STATE"); return internet && network; } return false; } private static string GetManifestPath() { return Path.Combine(Application.dataPath, "Plugins/Android/AndroidManifest.xml"); } private static void CreateManifest() { CopyFromPackage("Editor/SampleAndroidManifest.xml", GetManifestPath()); } private static void ConfigureManifest() { if (CheckManifestExists()) { AndroidManifest manifest = new AndroidManifest(GetManifestPath()); manifest.AddPermission("android.permission.INTERNET"); manifest.AddPermission("android.permission.ACCESS_NETWORK_STATE"); manifest.Save(); AssetDatabase.Refresh(); } } } internal class AndroidXmlDocument : XmlDocument { private string m_Path; protected XmlNamespaceManager nsMgr; public readonly string AndroidXmlNamespace = "http://schemas.android.com/apk/res/android"; public AndroidXmlDocument(string path) { m_Path = path; using (var reader = new XmlTextReader(m_Path)) { reader.Read(); Load(reader); } nsMgr = new XmlNamespaceManager(NameTable); nsMgr.AddNamespace("android", AndroidXmlNamespace); } public string Save() { return SaveAs(m_Path); } public string SaveAs(string path) { using (var writer = new XmlTextWriter(path, new UTF8Encoding(false))) { writer.Formatting = Formatting.Indented; Save(writer); } return path; } } internal class AndroidManifest : AndroidXmlDocument { private readonly XmlElement ApplicationElement; public AndroidManifest(string path) : base(path) { ApplicationElement = SelectSingleNode("/manifest/application") as XmlElement; } private XmlAttribute CreateAndroidAttribute(string key, string value) { XmlAttribute attr = CreateAttribute("android", key, AndroidXmlNamespace); attr.Value = value; return attr; } private XmlElement CreatePermissionElement(string permissionName) { XmlElement elem = CreateElement("uses-permission"); XmlAttribute attr = CreateAttribute("android", "name", AndroidXmlNamespace); attr.Value = permissionName; elem.Attributes.Append(attr); return elem; } internal XmlNode GetActivityWithLaunchIntent() { return SelectSingleNode("/manifest/application/activity[intent-filter/action/@android:name='android.intent.action.MAIN' and " + "intent-filter/category/@android:name='android.intent.category.LAUNCHER']", nsMgr); } internal void SetApplicationTheme(string appTheme) { ApplicationElement.Attributes.Append(CreateAndroidAttribute("theme", appTheme)); } internal void SetStartingActivityName(string activityName) { GetActivityWithLaunchIntent().Attributes.Append(CreateAndroidAttribute("name", activityName)); } internal void AddPermission(string permissionName) { XmlElement ManifestElement = SelectSingleNode("/manifest") as XmlElement; XmlElement permissionElement = CreatePermissionElement(permissionName); ManifestElement?.AppendChild(permissionElement); } internal bool CheckPermission(string permissionName) { XmlElement ManifestElement = SelectSingleNode("/manifest") as XmlElement; XmlNodeList children = ManifestElement?.ChildNodes; if (children == null) return false; foreach (XmlNode child in children) { if (child.Name != "uses-permission") continue; if (child.Attributes == null) continue; XmlAttribute attr = child.Attributes?["android:name"]; if (attr == null) continue; if (attr.Value == permissionName) return true; } return false; } } #endif }