mirror of
https://github.com/GrahamKracker/UnityExplorer.git
synced 2025-07-16 00:07:52 +08:00
More progress
This commit is contained in:
@ -6,11 +6,48 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using BF = System.Reflection.BindingFlags;
|
||||
using UnityExplorer.Core.Runtime;
|
||||
using System.Text;
|
||||
|
||||
namespace UnityExplorer
|
||||
{
|
||||
public static class ReflectionUtility
|
||||
{
|
||||
static ReflectionUtility()
|
||||
{
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
CacheTypes(asm);
|
||||
|
||||
AppDomain.CurrentDomain.AssemblyLoad += AssemblyLoaded;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, Type> allCachedTypes = new Dictionary<string, Type>();
|
||||
|
||||
private static void CacheTypes(Assembly asm)
|
||||
{
|
||||
foreach (var type in asm.TryGetTypes())
|
||||
{
|
||||
if (allCachedTypes.ContainsKey(type.FullName))
|
||||
continue;
|
||||
|
||||
if (type.FullName.ContainsIgnoreCase("PrivateImplementationDetails"))
|
||||
continue;
|
||||
|
||||
allCachedTypes.Add(type.FullName, type);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssemblyLoaded(object sender, AssemblyLoadEventArgs args)
|
||||
{
|
||||
if (args.LoadedAssembly == null)
|
||||
return;
|
||||
|
||||
s_cachedTypeInheritance.Clear();
|
||||
s_cachedGenericParameterInheritance.Clear();
|
||||
|
||||
CacheTypes(args.LoadedAssembly);
|
||||
}
|
||||
|
||||
|
||||
public const BF AllFlags = BF.Public | BF.Instance | BF.NonPublic | BF.Static;
|
||||
|
||||
public static bool ValueEqual<T>(this T objA, T objB)
|
||||
@ -119,28 +156,8 @@ namespace UnityExplorer
|
||||
/// <returns>The Type if found, otherwise null.</returns>
|
||||
public static Type GetTypeByName(string fullName)
|
||||
{
|
||||
s_typesByName.TryGetValue(fullName, out Type ret);
|
||||
|
||||
if (ret != null)
|
||||
return ret;
|
||||
|
||||
foreach (var type in from asm in AppDomain.CurrentDomain.GetAssemblies()
|
||||
from type in asm.TryGetTypes()
|
||||
select type)
|
||||
{
|
||||
if (type.FullName == fullName)
|
||||
{
|
||||
ret = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (s_typesByName.ContainsKey(fullName))
|
||||
s_typesByName[fullName] = ret;
|
||||
else
|
||||
s_typesByName.Add(fullName, ret);
|
||||
|
||||
return ret;
|
||||
allCachedTypes.TryGetValue(fullName, out Type type);
|
||||
return type;
|
||||
}
|
||||
|
||||
// cache for GetBaseTypes
|
||||
@ -180,49 +197,47 @@ namespace UnityExplorer
|
||||
}
|
||||
|
||||
// cache for GetImplementationsOf
|
||||
internal static readonly Dictionary<Type, HashSet<Type>> s_cachedTypeInheritance = new Dictionary<Type, HashSet<Type>>();
|
||||
internal static int s_lastAssemblyCount;
|
||||
internal static readonly Dictionary<string, HashSet<Type>> s_cachedTypeInheritance = new Dictionary<string, HashSet<Type>>();
|
||||
internal static readonly Dictionary<string, HashSet<Type>> s_cachedGenericParameterInheritance = new Dictionary<string, HashSet<Type>>();
|
||||
|
||||
/// <summary>
|
||||
/// Get all non-abstract implementations of the provided type (include itself, if not abstract) in the current AppDomain.
|
||||
/// Also works for generic parameters by analyzing the constraints.
|
||||
/// </summary>
|
||||
/// <param name="baseType">The base type, which can optionally be abstract / interface.</param>
|
||||
/// <returns>All implementations of the type in the current AppDomain.</returns>
|
||||
public static HashSet<Type> GetImplementationsOf(this Type baseType, bool allowAbstract, bool allowGeneric)
|
||||
{
|
||||
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
|
||||
var key = baseType.AssemblyQualifiedName;
|
||||
|
||||
if (!s_cachedTypeInheritance.ContainsKey(baseType) || assemblies.Length != s_lastAssemblyCount)
|
||||
if (!s_cachedTypeInheritance.ContainsKey(key))
|
||||
{
|
||||
if (assemblies.Length != s_lastAssemblyCount)
|
||||
{
|
||||
s_cachedTypeInheritance.Clear();
|
||||
s_lastAssemblyCount = assemblies.Length;
|
||||
}
|
||||
|
||||
var set = new HashSet<Type>();
|
||||
|
||||
if (!baseType.IsAbstract && !baseType.IsInterface)
|
||||
set.Add(baseType);
|
||||
|
||||
foreach (var asm in assemblies)
|
||||
var keys = allCachedTypes.Keys.ToArray();
|
||||
for (int i = 0; i < keys.Length; i++)
|
||||
{
|
||||
foreach (var t in asm.TryGetTypes().Where(t => (allowAbstract || (!t.IsAbstract && !t.IsInterface))
|
||||
&& (allowGeneric || !t.IsGenericType)))
|
||||
var type = allCachedTypes[keys[i]];
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
if (baseType.IsAssignableFrom(t) && !set.Contains(t))
|
||||
set.Add(t);
|
||||
}
|
||||
catch { }
|
||||
if ((type.IsAbstract && type.IsSealed) // ignore static classes
|
||||
|| (!allowAbstract && type.IsAbstract)
|
||||
|| (!allowGeneric && (type.IsGenericType || type.IsGenericTypeDefinition)))
|
||||
continue;
|
||||
|
||||
if (baseType.IsAssignableFrom(type) && !set.Contains(type))
|
||||
set.Add(type);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
s_cachedTypeInheritance.Add(baseType, set);
|
||||
s_cachedTypeInheritance.Add(key, set);
|
||||
}
|
||||
|
||||
return s_cachedTypeInheritance[baseType];
|
||||
return s_cachedTypeInheritance[key];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -25,6 +25,15 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
Instance = this;
|
||||
|
||||
TryLoadGameModules();
|
||||
|
||||
BuildDeobfuscationCache();
|
||||
AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoaded;
|
||||
}
|
||||
|
||||
private void OnAssemblyLoaded(object sender, AssemblyLoadEventArgs args)
|
||||
{
|
||||
foreach (var type in args.LoadedAssembly.TryGetTypes())
|
||||
TryCacheDeobfuscatedType(type);
|
||||
}
|
||||
|
||||
public override object Cast(object obj, Type castTo)
|
||||
@ -71,10 +80,6 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
|
||||
public override Type GetDeobfuscatedType(Type type)
|
||||
{
|
||||
if (!builtDeobCache)
|
||||
BuildDeobfuscationCache();
|
||||
|
||||
Type ret = type;
|
||||
try
|
||||
{
|
||||
var cppType = Il2CppType.From(type);
|
||||
@ -84,14 +89,11 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
}
|
||||
catch { }
|
||||
|
||||
return ret;
|
||||
return type;
|
||||
}
|
||||
|
||||
public override string ProcessTypeFullNameInString(Type type, string theString, ref string typeName)
|
||||
{
|
||||
if (!builtDeobCache)
|
||||
BuildDeobfuscationCache();
|
||||
|
||||
if (!Il2CppTypeNotNull(type))
|
||||
return theString;
|
||||
|
||||
@ -105,27 +107,6 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
return theString;
|
||||
}
|
||||
|
||||
//public override string ProcessTypeFullNameInString(Type type, string theString, ref string typeName)
|
||||
//{
|
||||
// if (!builtDeobCache)
|
||||
// BuildDeobfuscationCache();
|
||||
|
||||
// try
|
||||
// {
|
||||
// var cppType = Il2CppType.From(type);
|
||||
// if (s_deobfuscatedTypeNames.ContainsKey(cppType.FullName))
|
||||
// {
|
||||
// typeName = s_deobfuscatedTypeNames[cppType.FullName];
|
||||
// theString = theString.Replace(cppType.FullName, typeName);
|
||||
// }
|
||||
// }
|
||||
// catch
|
||||
// {
|
||||
// }
|
||||
|
||||
// return theString;
|
||||
//}
|
||||
|
||||
public override Type GetActualType(object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
@ -161,9 +142,9 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
return getType;
|
||||
}
|
||||
}
|
||||
catch // (Exception ex)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ExplorerCore.LogWarning("Exception in GetActualType: " + ex);
|
||||
ExplorerCore.LogWarning("Exception in GetActualType: " + ex);
|
||||
}
|
||||
|
||||
return type;
|
||||
@ -175,37 +156,36 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
// keep deobfuscated type name cache, used to display proper name.
|
||||
internal static Dictionary<string, string> s_deobfuscatedTypeNames = new Dictionary<string, string>();
|
||||
|
||||
private static bool builtDeobCache = false;
|
||||
|
||||
private static void BuildDeobfuscationCache()
|
||||
{
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in asm.TryGetTypes())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (type.CustomAttributes.Any(it => it.AttributeType.Name == "ObfuscatedNameAttribute"))
|
||||
{
|
||||
var cppType = Il2CppType.From(type);
|
||||
|
||||
if (!Il2CppToMonoType.ContainsKey(cppType.FullName))
|
||||
{
|
||||
Il2CppToMonoType.Add(cppType.AssemblyQualifiedName, type);
|
||||
s_deobfuscatedTypeNames.Add(cppType.FullName, type.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
TryCacheDeobfuscatedType(type);
|
||||
}
|
||||
|
||||
builtDeobCache = true;
|
||||
|
||||
if (s_deobfuscatedTypeNames.Count > 0)
|
||||
ExplorerCore.Log($"Built deobfuscation cache, count: {s_deobfuscatedTypeNames.Count}");
|
||||
}
|
||||
|
||||
private static void TryCacheDeobfuscatedType(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (type.CustomAttributes.Any(it => it.AttributeType.Name == "ObfuscatedNameAttribute"))
|
||||
{
|
||||
var cppType = Il2CppType.From(type);
|
||||
|
||||
if (!Il2CppToMonoType.ContainsKey(cppType.FullName))
|
||||
{
|
||||
Il2CppToMonoType.Add(cppType.AssemblyQualifiedName, type);
|
||||
s_deobfuscatedTypeNames.Add(cppType.FullName, type.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to get the Mono (Unhollowed) Type representation of the provided <see cref="Il2CppSystem.Type"/>.
|
||||
/// </summary>
|
||||
@ -213,9 +193,6 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
/// <returns>The Mono Type if found, otherwise null.</returns>
|
||||
public static Type GetMonoType(CppType cppType)
|
||||
{
|
||||
if (!builtDeobCache)
|
||||
BuildDeobfuscationCache();
|
||||
|
||||
string name = cppType.AssemblyQualifiedName;
|
||||
|
||||
if (Il2CppToMonoType.ContainsKey(name))
|
||||
@ -344,6 +321,7 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not currently using, not sure if its necessary anymore, was necessary to prevent crashes at one point.
|
||||
public override bool IsReflectionSupported(Type type)
|
||||
{
|
||||
try
|
||||
@ -467,7 +445,6 @@ namespace UnityExplorer.Core.Runtime.Il2Cpp
|
||||
var valueList = new List<object>();
|
||||
|
||||
var hashtable = value.TryCast(typeof(Il2CppSystem.Collections.Hashtable)) as Il2CppSystem.Collections.Hashtable;
|
||||
|
||||
if (hashtable != null)
|
||||
{
|
||||
EnumerateCppHashtable(hashtable, keyList, valueList);
|
||||
|
@ -9,8 +9,8 @@ namespace UnityExplorer.Core.Search
|
||||
{
|
||||
UnityObject,
|
||||
GameObject,
|
||||
Component,
|
||||
Custom,
|
||||
//Component,
|
||||
//Custom,
|
||||
Singleton,
|
||||
StaticClass
|
||||
}
|
||||
|
@ -11,6 +11,114 @@ namespace UnityExplorer.Core.Search
|
||||
{
|
||||
public static class SearchProvider
|
||||
{
|
||||
|
||||
private static bool Filter(Scene scene, SceneFilter filter)
|
||||
{
|
||||
switch (filter)
|
||||
{
|
||||
case SceneFilter.Any:
|
||||
return true;
|
||||
case SceneFilter.DontDestroyOnLoad:
|
||||
return scene == SceneHandler.DontDestroyScene;
|
||||
case SceneFilter.HideAndDontSave:
|
||||
return scene == SceneHandler.AssetScene;
|
||||
case SceneFilter.ActivelyLoaded:
|
||||
return scene != SceneHandler.DontDestroyScene && scene != SceneHandler.AssetScene;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static List<object> UnityObjectSearch(string input, string customTypeInput, SearchContext context,
|
||||
ChildFilter childFilter, SceneFilter sceneFilter)
|
||||
{
|
||||
var results = new List<object>();
|
||||
|
||||
Type searchType;
|
||||
switch (context)
|
||||
{
|
||||
case SearchContext.GameObject:
|
||||
searchType = typeof(GameObject);
|
||||
break;
|
||||
|
||||
case SearchContext.UnityObject:
|
||||
default:
|
||||
|
||||
if (!string.IsNullOrEmpty(customTypeInput))
|
||||
{
|
||||
if (ReflectionUtility.GetTypeByName(customTypeInput) is Type customType)
|
||||
{
|
||||
if (typeof(UnityEngine.Object).IsAssignableFrom(customType))
|
||||
{
|
||||
searchType = customType;
|
||||
break;
|
||||
}
|
||||
else
|
||||
ExplorerCore.LogWarning($"Custom type '{customType.FullName}' is not assignable from UnityEngine.Object!");
|
||||
}
|
||||
else
|
||||
ExplorerCore.LogWarning($"Could not find any type by name '{customTypeInput}'!");
|
||||
}
|
||||
|
||||
searchType = typeof(UnityEngine.Object);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (searchType == null)
|
||||
return results;
|
||||
|
||||
var allObjects = RuntimeProvider.Instance.FindObjectsOfTypeAll(searchType);
|
||||
|
||||
// perform filter comparers
|
||||
|
||||
string nameFilter = null;
|
||||
if (!string.IsNullOrEmpty(input))
|
||||
nameFilter = input;
|
||||
|
||||
bool canGetGameObject = context == SearchContext.GameObject || typeof(Component).IsAssignableFrom(searchType);
|
||||
|
||||
foreach (var obj in allObjects)
|
||||
{
|
||||
// name check
|
||||
if (!string.IsNullOrEmpty(nameFilter) && !obj.name.ContainsIgnoreCase(nameFilter))
|
||||
continue;
|
||||
|
||||
if (canGetGameObject)
|
||||
{
|
||||
var go = context == SearchContext.GameObject
|
||||
? obj.TryCast<GameObject>()
|
||||
: obj.TryCast<Component>().gameObject;
|
||||
|
||||
if (go)
|
||||
{
|
||||
// scene check
|
||||
if (sceneFilter != SceneFilter.Any)
|
||||
{
|
||||
if (!Filter(go.scene, sceneFilter))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (childFilter != ChildFilter.Any)
|
||||
{
|
||||
if (!go)
|
||||
continue;
|
||||
|
||||
// root object check (no parent)
|
||||
if (childFilter == ChildFilter.HasParent && !go.transform.parent)
|
||||
continue;
|
||||
else if (childFilter == ChildFilter.RootObject && go.transform.parent)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(obj);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
internal static List<object> StaticClassSearch(string input)
|
||||
{
|
||||
var list = new List<object>();
|
||||
@ -76,125 +184,5 @@ namespace UnityExplorer.Core.Search
|
||||
return instances;
|
||||
}
|
||||
|
||||
private static bool Filter(Scene scene, SceneFilter filter)
|
||||
{
|
||||
switch (filter)
|
||||
{
|
||||
case SceneFilter.Any:
|
||||
return true;
|
||||
case SceneFilter.DontDestroyOnLoad:
|
||||
return scene == SceneHandler.DontDestroyScene;
|
||||
case SceneFilter.HideAndDontSave:
|
||||
return scene == SceneHandler.AssetScene;
|
||||
case SceneFilter.ActivelyLoaded:
|
||||
return scene != SceneHandler.DontDestroyScene && scene != SceneHandler.AssetScene;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static List<object> UnityObjectSearch(string input, string customTypeInput, SearchContext context,
|
||||
ChildFilter childFilter, SceneFilter sceneFilter)
|
||||
{
|
||||
var results = new List<object>();
|
||||
|
||||
Type searchType = null;
|
||||
switch (context)
|
||||
{
|
||||
case SearchContext.GameObject:
|
||||
searchType = typeof(GameObject); break;
|
||||
|
||||
case SearchContext.Component:
|
||||
searchType = typeof(Component); break;
|
||||
|
||||
case SearchContext.Custom:
|
||||
if (string.IsNullOrEmpty(customTypeInput))
|
||||
{
|
||||
ExplorerCore.LogWarning("Custom Type input must not be empty!");
|
||||
return results;
|
||||
}
|
||||
if (ReflectionUtility.GetTypeByName(customTypeInput) is Type customType)
|
||||
{
|
||||
if (typeof(UnityEngine.Object).IsAssignableFrom(customType))
|
||||
searchType = customType;
|
||||
else
|
||||
ExplorerCore.LogWarning($"Custom type '{customType.FullName}' is not assignable from UnityEngine.Object!");
|
||||
}
|
||||
else
|
||||
ExplorerCore.LogWarning($"Could not find a type by the name '{customTypeInput}'!");
|
||||
break;
|
||||
|
||||
default:
|
||||
searchType = typeof(UnityEngine.Object); break;
|
||||
}
|
||||
|
||||
|
||||
if (searchType == null)
|
||||
return results;
|
||||
|
||||
var allObjects = RuntimeProvider.Instance.FindObjectsOfTypeAll(searchType);
|
||||
|
||||
// perform filter comparers
|
||||
|
||||
string nameFilter = null;
|
||||
if (!string.IsNullOrEmpty(input))
|
||||
nameFilter = input;
|
||||
|
||||
bool canGetGameObject = (sceneFilter != SceneFilter.Any || childFilter != ChildFilter.Any)
|
||||
&& (context == SearchContext.GameObject || typeof(Component).IsAssignableFrom(searchType));
|
||||
|
||||
if (!canGetGameObject)
|
||||
{
|
||||
if (context != SearchContext.UnityObject && (sceneFilter != SceneFilter.Any || childFilter != ChildFilter.Any))
|
||||
ExplorerCore.LogWarning($"Type '{searchType}' cannot have Scene or Child filters applied to it");
|
||||
}
|
||||
|
||||
foreach (var obj in allObjects)
|
||||
{
|
||||
// name check
|
||||
if (!string.IsNullOrEmpty(nameFilter) && !obj.name.ContainsIgnoreCase(nameFilter))
|
||||
continue;
|
||||
|
||||
if (canGetGameObject)
|
||||
{
|
||||
var go = context == SearchContext.GameObject
|
||||
? obj.TryCast<GameObject>()
|
||||
: obj.TryCast<Component>().gameObject;
|
||||
|
||||
// scene check
|
||||
if (sceneFilter != SceneFilter.Any)
|
||||
{
|
||||
if (!go)
|
||||
continue;
|
||||
|
||||
switch (context)
|
||||
{
|
||||
case SearchContext.GameObject:
|
||||
case SearchContext.Custom:
|
||||
case SearchContext.Component:
|
||||
if (!Filter(go.scene, sceneFilter))
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (childFilter != ChildFilter.Any)
|
||||
{
|
||||
if (!go)
|
||||
continue;
|
||||
|
||||
// root object check (no parent)
|
||||
if (childFilter == ChildFilter.HasParent && !go.transform.parent)
|
||||
continue;
|
||||
else if (childFilter == ChildFilter.RootObject && go.transform.parent)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(obj);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,6 +76,31 @@ namespace UnityExplorer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
private static void TestGeneric<T>()
|
||||
{
|
||||
ExplorerCore.Log("Test1 " + typeof(T).FullName);
|
||||
}
|
||||
|
||||
private static void TestGenericClass<T>() where T : class
|
||||
{
|
||||
ExplorerCore.Log("Test2 " + typeof(T).FullName);
|
||||
}
|
||||
|
||||
//private static void TestGenericMultiInterface<T>() where T : IEnumerable, IList, ICollection
|
||||
//{
|
||||
// ExplorerCore.Log("Test3 " + typeof(T).FullName);
|
||||
//}
|
||||
|
||||
private static void TestComponent<T>() where T : Component
|
||||
{
|
||||
ExplorerCore.Log("Test3 " + typeof(T).FullName);
|
||||
}
|
||||
|
||||
private static void TestStruct<T>() where T : struct
|
||||
{
|
||||
ExplorerCore.Log("Test3 " + typeof(T).FullName);
|
||||
}
|
||||
|
||||
private static object GetRandomObject()
|
||||
{
|
||||
object ret = null;
|
||||
|
@ -10,9 +10,21 @@ namespace UnityExplorer
|
||||
{
|
||||
private static CultureInfo _enCulture = new CultureInfo("en-US");
|
||||
|
||||
/// <summary>
|
||||
/// Check if a string contains another string, case-insensitive.
|
||||
/// </summary>
|
||||
public static bool ContainsIgnoreCase(this string _this, string s)
|
||||
{
|
||||
return _enCulture.CompareInfo.IndexOf(_this, s, CompareOptions.IgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Just to allow Enum to do .HasFlag() in NET 3.5
|
||||
/// </summary>
|
||||
public static bool HasFlag(this Enum flags, Enum value)
|
||||
{
|
||||
ulong num = Convert.ToUInt64(value);
|
||||
return (Convert.ToUInt64(flags) & num) == num;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user