/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* Licensed under the Oculus SDK License Agreement (the "License");
* you may not use the Oculus SDK except in compliance with the License,
* which is provided at the time of installation or download, or which
* otherwise accompanies this software in either electronic or hard copy form.
*
* You may obtain a copy of the License at
*
* https://developer.oculus.com/licenses/oculussdk/
*
* Unless required by applicable law or agreed to in writing, the Oculus SDK
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using UnityEditor.PackageManager;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace Oculus.Interaction.Editor
{
[InitializeOnLoad]
public static class PackageCleanup
{
private enum CleanupOperation
{
None,
Delete,
Move,
StripTags,
}
private class CleanupInfo
{
public CleanupOperation Operation;
public GUID AssetGuid;
public GUID MoveToPathGuid;
}
private enum CleanupResult
{
None,
Success,
Cancel,
Incomplete,
}
public const string PACKAGE_VERSION = "0.57.0";
public const string DEPRECATED_TAG = "oculus_interaction_deprecated";
public const string MOVED_TAG = "oculus_interaction_moved_";
private const string MENU_NAME = "Oculus/Interaction/Clean Up Package";
private const string AUTO_CLEANUP_KEY = "Oculus_Interaction_AutoCleanUp_" + PACKAGE_VERSION;
private static bool AutoCleanup
{
get => PlayerPrefs.GetInt(AUTO_CLEANUP_KEY, 1) == 1;
set => PlayerPrefs.SetInt(AUTO_CLEANUP_KEY, value ? 1 : 0);
}
static PackageCleanup()
{
EditorApplication.delayCall += HandleDelayCall;
}
[MenuItem(MENU_NAME)]
private static void AssetRemovalMenuCommand()
{
AutoCleanup = true;
StartRemovalUserFlow(true);
}
private static void HandleDelayCall()
{
bool startAutoDeprecation = !Application.isBatchMode &&
AutoCleanup &&
!Application.isPlaying;
if (startAutoDeprecation)
{
StartRemovalUserFlow(false);
}
}
///
/// Check if there are any assets in the project that require
/// cleanup operations.
///
/// True if package needs cleanup
public static bool CheckPackageNeedsCleanup()
{
return GetAssetInfos().Count > 0;
}
///
/// Start the removal flow for removing deprecated assets.
///
/// If true, the window will
/// be non-modal, and a dialog will be shown if no assets found
public static void StartRemovalUserFlow(bool userTriggered)
{
var assetInfos = GetAssetInfos();
if (assetInfos.Count == 0)
{
if (userTriggered)
{
EditorUtility.DisplayDialog("Interaction SDK",
"No clean up needed in package.", "Close");
}
else
{
return;
}
}
else
{
int deletionPromptResult = EditorUtility.DisplayDialogComplex(
"Interaction SDK",
"This utility performs a cleanup operation which relocates " +
"Interaction SDK files and folders, and removes asset stubs provided " +
"for backwards compatibility during package upgrade." +
"\n\n" +
"Click 'Show Assets' to view a list of the assets to be modified. " +
"You will then be given the option to run the cleanup operation on them.",
"Show Assets (Recommended)", "No, Don't Ask Again", "No");
switch (deletionPromptResult)
{
case 0: // "Yes"
bool modalWindow = !userTriggered;
ShowAssetCleanupWindow(assetInfos, modalWindow);
break;
case 1: // "No, Don't Ask Again"
AutoCleanup = false;
ShowCancelDialog();
break;
default:
case 2: // "No"
AutoCleanup = true;
break;
}
}
}
private static IReadOnlyList GetAssetInfos()
{
List result = new List();
var deprecatedGUIDs = AssetDatabase.FindAssets($"l:{DEPRECATED_TAG}", null)
.Select((guidStr) => new GUID(guidStr));
var movedGUIDs = AssetDatabase.FindAssets($"l:{MOVED_TAG}", null)
.Select((guidStr) => new GUID(guidStr));
foreach (var GUID in deprecatedGUIDs)
{
result.Add(new CleanupInfo()
{
Operation = CleanupOperation.Delete,
AssetGuid = GUID,
});
}
foreach (var GUID in movedGUIDs)
{
if (GetDestFolderForMovedAsset(GUID, out GUID newPathGUID))
{
result.Add(new CleanupInfo()
{
Operation = CleanupOperation.Move,
AssetGuid = GUID,
MoveToPathGuid = newPathGUID,
});
}
else
{
result.Add(new CleanupInfo()
{
Operation = CleanupOperation.StripTags,
AssetGuid = GUID,
});
}
}
result.RemoveAll((info) =>
{
// Ignore assets in read-only packages
var pSource = PackageInfo.FindForAssetPath(
AssetDatabase.GUIDToAssetPath(info.AssetGuid))?.source;
return pSource != null && // In Assets folder
pSource != PackageSource.Embedded &&
pSource != PackageSource.Local;
});
return result;
}
private static void ShowAssetCleanupWindow(
IEnumerable cleanupInfos, bool modal)
{
void DrawHeader(AssetListWindow window)
{
EditorGUILayout.HelpBox(
"Assets marked Delete will be permanently deleted",
MessageType.Warning);
}
void DrawFooter(AssetListWindow window)
{
GUILayoutOption buttonHeight = GUILayout.Height(36);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Clean Up (Recommended)", buttonHeight))
{
var result = CleanUpAssets(cleanupInfos);
switch (result)
{
default:
case CleanupResult.None:
case CleanupResult.Cancel:
AutoCleanup = true;
break;
case CleanupResult.Success:
case CleanupResult.Incomplete:
AutoCleanup = false;
window.Close();
break;
}
}
if (GUILayout.Button("Cancel", buttonHeight))
{
ShowCancelDialog();
}
EditorGUILayout.EndHorizontal();
}
List windowInfos =
new List();
foreach (var info in cleanupInfos)
{
switch (info.Operation)
{
default:
case CleanupOperation.None:
break;
case CleanupOperation.Delete:
windowInfos.Add(new AssetListWindow.AssetInfo(
GUIDToAssetPath(info.AssetGuid),
$"Delete: " +
$"{GUIDToAssetPath(info.AssetGuid)}"));
break;
case CleanupOperation.Move:
windowInfos.Add(new AssetListWindow.AssetInfo(
GUIDToAssetPath(info.AssetGuid),
$"Move: " +
$"{GUIDToAssetPath(info.AssetGuid)} -> " +
$"{GUIDToAssetPath(info.MoveToPathGuid)}"));
break;
case CleanupOperation.StripTags:
windowInfos.Add(new AssetListWindow.AssetInfo(
GUIDToAssetPath(info.AssetGuid),
$"Unlabel: " +
$"{GUIDToAssetPath(info.AssetGuid)}"));
break;
}
}
AssetListWindow assetListWindow = AssetListWindow.Show(
"Interaction SDK - All Assets to be Modified",
windowInfos, modal, DrawHeader, DrawFooter);
}
private static void ShowCancelDialog()
{
AssetListWindow.CloseAll();
EditorUtility.DisplayDialog("Interaction SDK",
$"Package cleanup was not run. " +
$"You can run this utility at any time " +
$"using the '{MENU_NAME}' menu.",
"Close");
}
private static bool GetDestFolderForMovedAsset(GUID assetGUID, out GUID destFolderGUID)
{
destFolderGUID = new GUID();
Object assetObject = AssetDatabase.LoadMainAssetAtPath(GUIDToAssetPath(assetGUID));
List labels = new List(AssetDatabase.GetLabels(assetObject));
int index = labels.FindIndex((l) => l.Contains(MOVED_TAG));
if (index >= 0)
{
destFolderGUID = new GUID(labels[index].Remove(0, MOVED_TAG.Length));
// Verify that paths exist, and new path is not the same as old path
string curPath = Path.GetFullPath(GUIDToAssetPath(assetGUID));
string newFolder = Path.GetFullPath(GUIDToAssetPath(destFolderGUID));
string targetFilePath = Path.Combine(newFolder, Path.GetFileName(curPath));
if (!curPath.Equals(targetFilePath) &&
(Directory.Exists(curPath) || File.Exists(curPath)) &&
Directory.Exists(newFolder))
{
return true;
}
}
return false;
}
private static CleanupResult CleanUpAssets(IEnumerable cleanupInfos)
{
if (EditorUtility.DisplayDialog("Are you sure?",
"Any assets marked for deletion will be permanently deleted." +
"\n\n" +
"It is strongly recommended that you back up your project before proceeding.",
"Clean Up Package", "Cancel"))
{
var deletions = new List();
var moves = new Dictionary();
var stripTags = new List();
foreach (var info in cleanupInfos)
{
switch (info.Operation)
{
default:
case CleanupOperation.None:
break;
case CleanupOperation.Delete:
deletions.Add(info.AssetGuid);
break;
case CleanupOperation.Move:
moves.Add(info.AssetGuid, info.MoveToPathGuid);
break;
case CleanupOperation.StripTags:
stripTags.Add(info.AssetGuid);
break;
}
}
bool result = true;
result &= MoveAssets(moves);
result &= DeleteAssets(deletions);
result &= StripTags(stripTags);
return result ? CleanupResult.Success : CleanupResult.Incomplete;
}
else
{
return CleanupResult.Cancel;
}
}
private static bool MoveAssets(IDictionary curToNewPathGUID)
{
Dictionary moves = new Dictionary();
Dictionary failures = new Dictionary();
foreach (var assetGUID in curToNewPathGUID.Keys)
{
if (!curToNewPathGUID.TryGetValue(assetGUID, out GUID newPathGUID))
{
string failedPath = GUIDToAssetPath(assetGUID);
failures.Add(failedPath, $"No new path provided for asset {failedPath}");
continue;
}
string curPath = GUIDToAssetPath(assetGUID);
string newPath = Path.Combine(GUIDToAssetPath(newPathGUID),
Path.GetFileName(curPath));
if (Path.GetFullPath(curPath).Equals(Path.GetFullPath(newPath)))
{
// Source and destination paths already match
continue;
}
string result = AssetDatabase.MoveAsset(curPath, newPath);
if (!string.IsNullOrEmpty(result))
{
failures.Add(curPath, result);
}
else
{
// Strip labels after successful move
StripTag(assetGUID, MOVED_TAG);
moves.Add(curPath, newPath);
}
}
string logMessage;
if (BuildLogMessage("Assets moved:",
moves.Keys.Select((key) => $"{key} -> {moves[key]}"),
out logMessage))
{
Debug.Log(logMessage);
}
if (BuildLogMessage("Could not move assets:",
failures.Keys.Select((key) => $"{key}:{failures[key]}"),
out logMessage))
{
Debug.LogError(logMessage);
}
return failures.Count == 0;
}
private static bool DeleteAssets(IEnumerable assetGUIDs)
{
var assetPaths = assetGUIDs
.Select((guid) => GUIDToAssetPath(guid));
HashSet filesToDelete = new HashSet();
HashSet foldersToDelete = new HashSet();
HashSet skippedFolders = new HashSet();
HashSet failedPaths = new HashSet();
foreach (var path in assetPaths)
{
if (File.Exists(path))
{
filesToDelete.Add(path);
}
else if (Directory.Exists(path))
{
foldersToDelete.Add(path);
}
else
{
failedPaths.Add(path);
}
}
#if UNITY_2020_1_OR_NEWER
List failed = new List();
// Delete files
AssetDatabase.DeleteAssets(filesToDelete.ToArray(), failed);
failedPaths.UnionWith(failed);
// Remove non-empty folders from delete list
skippedFolders.UnionWith(foldersToDelete
.Where((path) => AssetDatabase.FindAssets("", new[] { path })
.Select((guid) => AssetDatabase.GUIDToAssetPath(guid))
.Any((path) => !AssetDatabase.IsValidFolder(path))));
foldersToDelete.ExceptWith(skippedFolders);
// Delete folders, removing longest paths (subfolders) first
List sortedFolders = new List(foldersToDelete);
sortedFolders.Sort((a, b) => b.Length.CompareTo(a.Length));
AssetDatabase.DeleteAssets(sortedFolders.ToArray(), failed);
failedPaths.UnionWith(failed);
#else
// Delete files
foreach (var path in filesToDelete)
{
if (!AssetDatabase.DeleteAsset(path))
{
failedPaths.Add(path);
}
}
// Remove non-empty folders from delete list
skippedFolders.UnionWith(foldersToDelete
.Where((path) => Directory.EnumerateFiles(path).Any()));
foldersToDelete.ExceptWith(skippedFolders);
// Delete folders
foreach (var path in foldersToDelete)
{
if (!AssetDatabase.DeleteAsset(path))
{
failedPaths.Add(path);
}
}
#endif
string logMessage;
if (BuildLogMessage("Deprecated assets deleted:",
filesToDelete.Union(foldersToDelete), out logMessage))
{
Debug.Log(logMessage);
}
if (BuildLogMessage("Skipped non-empty folders:",
skippedFolders, out logMessage))
{
Debug.LogWarning(logMessage);
}
if (BuildLogMessage("Failed to delete assets:",
failedPaths, out logMessage))
{
Debug.LogError(logMessage);
}
return failedPaths.Count == 0;
}
private static bool StripTags(IEnumerable assetGUIDs)
{
foreach (var GUID in assetGUIDs)
{
StripTag(GUID, DEPRECATED_TAG);
StripTag(GUID, MOVED_TAG);
}
return true;
}
private static void StripTag(in GUID assetGUID, string tag)
{
string assetPath = GUIDToAssetPath(assetGUID);
Object assetObject = AssetDatabase.LoadMainAssetAtPath(assetPath);
List labels = new List(AssetDatabase.GetLabels(assetObject));
labels.RemoveAll((l) => l.Contains(tag));
AssetDatabase.SetLabels(assetObject, labels.ToArray());
}
private static bool BuildLogMessage(
string title,
IEnumerable messages,
out string message)
{
int count = 0;
StringBuilder sb = new StringBuilder();
sb.Append(title);
foreach (var msg in messages)
{
sb.Append(System.Environment.NewLine);
sb.Append(msg);
++count;
}
message = sb.ToString();
return count > 0;
}
private static string GUIDToAssetPath(GUID guid)
{
#if UNITY_2020_3_OR_NEWER
return AssetDatabase.GUIDToAssetPath(guid);
#else
return AssetDatabase.GUIDToAssetPath(guid.ToString());
#endif
}
}
}