using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using JetBrains.Annotations; using Newtonsoft.Json; using UnityEngine; using UnityEngine.Networking; using VelNet; namespace VELConnect { // ReSharper disable once InconsistentNaming public class VELConnectManager : MonoBehaviour { public string velConnectUrl = "http://localhost"; public static string VelConnectUrl => instance.velConnectUrl; private static VELConnectManager instance; public class State { public Device device; public DataBlock room; public User user; public class User { public string id; public string username; public string created; public string updated; } public class Device { [CanBeNull] public readonly string id; [CanBeNull] public string created = null; [CanBeNull] public string updated = null; [CanBeNull] public string os_info; [CanBeNull] public string friendly_name; [CanBeNull] public string modified_by; [CanBeNull] public string current_app; [CanBeNull] public string current_room; [CanBeNull] public string pairing_code; [CanBeNull] public string last_online; [CanBeNull] public string owner; [CanBeNull] public string[] past_owners; [CanBeNull] public DeviceExpand expand; public DataBlock userData => expand?.data; public class DeviceExpand { public DataBlock data; } /// /// Returns the value if it exists, otherwise null /// public string TryGetData(string key) { return userData.TryGetData(key); } } public class DataBlock { public readonly string id; public readonly DateTime created; public readonly DateTime updated; public string block_id; public string owner; public string category; public string modified_by; public Dictionary data; /// /// Returns the value if it exists, otherwise null /// public string TryGetData(string key) { string val = null; return data?.TryGetValue(key, out val) == true ? val : null; } } } public class UserCount { [CanBeNull] public readonly string id; public readonly DateTime? created; public readonly DateTime? updated; public string device_id; public string app_id; public string room_id; public int total_users; public int room_users; public string version; public string platform; } public enum DeviceField { id, os_info, friendly_name, modified_by, current_app, current_room, pairing_code, last_online, data, owner, past_owners } private State lastState; private State state; [CanBeNull] public static State CurrentState => instance == null ? null : instance.state; public static Action OnInitialState; public static Action OnDeviceFieldChanged; public static Action OnUserDataChanged; public static Action OnRoomDataChanged; private static readonly Dictionary> deviceFieldCallbacks = new Dictionary>(); private static readonly Dictionary> userDataCallbacks = new Dictionary>(); private static readonly Dictionary> roomDataCallbacks = new Dictionary>(); private struct CallbackListener { /// /// Used so that other objects don't have to remove listeners themselves /// public MonoBehaviour keepAliveObject; public Action callback; /// /// Sends the first state received from the network or the state at binding time /// public bool sendInitialState; } public static string PairingCode { get { Hash128 hash = new Hash128(); hash.Append(deviceId); // change once a day hash.Append(DateTime.UtcNow.DayOfYear); // between 1000 and 9999 inclusive (any 4 digit number) return (Math.Abs(hash.GetHashCode()) % 9000 + 1000).ToString(); } } private static string deviceId; private void Awake() { if (instance != null) Debug.LogError("VELConnectManager instance already exists", this); instance = this; // Compute device id MD5CryptoServiceProvider md5 = new MD5CryptoServiceProvider(); StringBuilder sb = new StringBuilder(SystemInfo.deviceUniqueIdentifier); sb.Append(Application.productName); #if UNITY_EDITOR // allows running multiple builds on the same computer // return SystemInfo.deviceUniqueIdentifier + Hash128.Compute(Application.dataPath); sb.Append(Application.dataPath); sb.Append("EDITOR"); #endif string id = Convert.ToBase64String(md5.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()))); deviceId = id[..15]; } // Start is called before the first frame update private void Start() { SetDeviceField(new Dictionary { { DeviceField.os_info, SystemInfo.operatingSystem }, { DeviceField.friendly_name, SystemInfo.deviceName }, { DeviceField.current_app, Application.productName }, { DeviceField.pairing_code, PairingCode }, }); UpdateUserCount(); StartCoroutine(SlowLoop()); VelNetManager.OnJoinedRoom += room => { SetDeviceField(new Dictionary { { DeviceField.current_app, Application.productName }, { DeviceField.current_room, room }, }); }; } private void UpdateUserCount(bool leaving = false) { if (!VelNetManager.InRoom) return; VelNetManager.GetRooms(rooms => { UserCount postData = new UserCount { device_id = deviceId, app_id = Application.productName, room_id = VelNetManager.Room ?? "", total_users = rooms.rooms.Sum(r => r.numUsers) - (leaving ? 1 : 0), room_users = VelNetManager.PlayerCount - (leaving ? 1 : 0), version = Application.version, platform = SystemInfo.operatingSystem, }; PostRequestCallback(velConnectUrl + "/api/collections/UserCount/records", JsonConvert.SerializeObject( postData, Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore })); }); } private IEnumerator SlowLoop() { while (true) { try { GetRequestCallback(velConnectUrl + "/state/" + deviceId, json => { state = JsonConvert.DeserializeObject(json); if (state == null) return; bool isInitialState = false; // first load stuff if (lastState == null) { try { OnInitialState?.Invoke(state); } catch (Exception e) { Debug.LogError(e); } isInitialState = true; // lastState = state; // return; } // if (state.device.modified_by != DeviceId) { FieldInfo[] fields = state.device.GetType().GetFields(); // loop through all the fields in the device foreach (FieldInfo fieldInfo in fields) { string newValue = fieldInfo.GetValue(state.device) as string; string oldValue = lastState != null ? fieldInfo.GetValue(lastState.device) as string : null; DeviceField fieldName; if (Enum.TryParse(fieldInfo.Name, out fieldName)) { if (newValue != oldValue) { try { if (!isInitialState) OnDeviceFieldChanged?.Invoke(fieldName, newValue); } catch (Exception e) { Debug.LogError(e); } // send specific listeners data if (deviceFieldCallbacks.ContainsKey(fieldName)) { // clear the list of old listeners deviceFieldCallbacks[fieldName].RemoveAll(e => e.keepAliveObject == null); // send the callbacks foreach (CallbackListener e in deviceFieldCallbacks[fieldName]) { if (!isInitialState || e.sendInitialState) { try { e.callback(newValue); } catch (Exception ex) { Debug.LogError(ex); } } } } } } } if (state.device.userData.data != null) { foreach (KeyValuePair elem in state.device.userData.data) { string oldValue = null; lastState?.device?.userData?.data?.TryGetValue(elem.Key, out oldValue); if (elem.Value != oldValue) { try { if (!isInitialState) OnUserDataChanged?.Invoke(elem.Key, elem.Value); } catch (Exception ex) { Debug.LogError(ex); } // send specific listeners data if (userDataCallbacks.ContainsKey(elem.Key)) { // clear the list of old listeners userDataCallbacks[elem.Key].RemoveAll(e => e.keepAliveObject == null); // send the callbacks foreach (CallbackListener e in userDataCallbacks[elem.Key]) { if (!isInitialState || e.sendInitialState) { try { e.callback(elem.Value); } catch (Exception ex) { Debug.LogError(ex); } } } } } } // on the initial state, also activate callbacks for null values if (isInitialState) { foreach ((string userDataKey, List callbackList) in userDataCallbacks) { if (!state.device.userData.data.ContainsKey(userDataKey)) { // send the callbacks callbackList.ForEach(e => { if (e.sendInitialState) { try { e.callback(null); } catch (Exception ex) { Debug.LogError(ex); } } }); } } } } } // if (state.room.modified_by != DeviceId && state.room.data != null) if (state.room?.data != null) { foreach (KeyValuePair elem in state.room.data) { string oldValue = null; lastState?.room.data.TryGetValue(elem.Key, out oldValue); if (elem.Value != oldValue) { try { if (!isInitialState) OnRoomDataChanged?.Invoke(elem.Key, elem.Value); } catch (Exception e) { Debug.LogError(e); } // send specific listeners data if (roomDataCallbacks.ContainsKey(elem.Key)) { // clear the list of old listeners roomDataCallbacks[elem.Key].RemoveAll(e => e.keepAliveObject == null); // send the callbacks roomDataCallbacks[elem.Key].ForEach(e => { if (!isInitialState || e.sendInitialState) { try { e.callback(elem.Value); } catch (Exception ex) { Debug.LogError(ex); } } }); } } } // on the initial state, also activate callbacks for null values if (isInitialState) { foreach ((string key, List callbackList) in roomDataCallbacks) { if (!state.room.data.ContainsKey(key)) { // send the callbacks callbackList.ForEach(e => { if (e.sendInitialState) { try { e.callback(null); } catch (Exception ex) { Debug.LogError(ex); } } }); } } } } lastState = state; if (lastState?.device?.pairing_code == null) { Debug.LogError("Pairing code nulllll"); } }); } catch (Exception e) { Debug.LogError(e); // this make sure the coroutine never quits } yield return new WaitForSeconds(1); } } /// /// Adds a change listener callback to a particular field name within the Device main fields. /// public static void AddDeviceFieldListener(DeviceField key, MonoBehaviour keepAliveObject, Action callback, bool sendInitialState = false) { if (!deviceFieldCallbacks.ContainsKey(key)) { deviceFieldCallbacks[key] = new List(); } deviceFieldCallbacks[key].Add(new CallbackListener() { keepAliveObject = keepAliveObject, callback = callback, sendInitialState = sendInitialState }); if (sendInitialState) { if (instance != null && instance.lastState?.device != null) { if (instance.lastState.device.GetType().GetField(key.ToString()) ?.GetValue(instance.lastState.device) is string val) { try { callback(val); } catch (Exception e) { Debug.LogError(e); } } } } } /// /// Adds a change listener callback to a particular field name within the User data JSON. /// If the initial state doesn't contain this key, this sends back null /// public static void AddUserDataListener(string key, MonoBehaviour keepAliveObject, Action callback, bool sendInitialState = false) { if (!userDataCallbacks.ContainsKey(key)) { userDataCallbacks[key] = new List(); } userDataCallbacks[key].Add(new CallbackListener() { keepAliveObject = keepAliveObject, callback = callback, sendInitialState = sendInitialState }); // if we have already received data, and we should send right away if (sendInitialState && instance.state != null) { string val = GetUserData(key); try { callback(val); } catch (Exception e) { Debug.LogError(e); } } } /// /// Adds a change listener callback to a particular field name within the Room data JSON. /// If the initial state doesn't contain this key, this sends back null /// public static void AddRoomDataListener(string key, MonoBehaviour keepAliveObject, Action callback, bool sendInitialState = false) { if (!roomDataCallbacks.ContainsKey(key)) { roomDataCallbacks[key] = new List(); } roomDataCallbacks[key].Add(new CallbackListener() { keepAliveObject = keepAliveObject, callback = callback, sendInitialState = sendInitialState }); // if we have already received data, and we should send right away if (sendInitialState && instance.state != null) { string val = GetRoomData(key); try { callback(val); } catch (Exception e) { Debug.LogError(e); } } } public static string GetUserData(string key, string defaultValue = null) { return instance != null ? instance.lastState?.device?.TryGetData(key) : defaultValue; } public static string GetRoomData(string key, string defaultValue = null) { return instance != null ? instance.lastState?.room?.TryGetData(key) : defaultValue; } public static T GetRoomData(string key, string defaultValue = null) { string value = instance != null ? instance.lastState?.room?.TryGetData(key) : defaultValue; if (typeof(T) == typeof(int)) { if (int.TryParse(value, out int result)) { return (T)Convert.ChangeType(result, typeof(T)); } return (T)(object)0; } else if (typeof(T) == typeof(double)) { if (double.TryParse(value, out double result)) { return (T)Convert.ChangeType(result, typeof(T)); } return (T)(object)0.0; } else if (typeof(T) == typeof(float)) { if (float.TryParse(value, out float result)) { return (T)Convert.ChangeType(result, typeof(T)); } return (T)(object)0f; } throw new NotSupportedException($"Conversion to type {typeof(T)} is not supported."); } /// /// Sets data on the device keys themselves /// These are fixed fields defined for every application /// public static void SetDeviceField(Dictionary device) { device[DeviceField.last_online] = DateTime.UtcNow.ToLongDateString(); if (instance.state?.device != null) { // loop through all the fields in the device foreach (DeviceField key in device.Keys.ToArray()) { FieldInfo field = instance.state.device.GetType().GetField(key.ToString()); if ((string)field.GetValue(instance.state.device) != device[key]) { if (instance.lastState?.device != null) { // update our local state, so we don't get change events on our own updates field.SetValue(instance.lastState.device, device[key]); } } else { // don't send this field, since it's the same device.Remove(key); } } // last_online field always changes if (device.Keys.Count <= 1) { // nothing changed, don't send return; } } PostRequestCallback( instance.velConnectUrl + "/device/" + deviceId, JsonConvert.SerializeObject(device) ); } /// /// Sets the 'data' object of the Device table /// public static void SetUserData(Dictionary data) { if (instance.state?.device != null) { foreach (string key in data.Keys.ToList()) { // if the value is unchanged from the current state, remove it so we don't double-update if (instance.state.device.userData.data.TryGetValue(key, out string val) && val == data[key]) { data.Remove(key); } else { // update our local state, so we don't get change events on our own updates if (instance.lastState?.device?.userData?.data != null) { instance.lastState.device.userData.data[key] = data[key]; } } } // nothing was changed if (data.Keys.Count == 0) { return; } // if we have no data, just set the whole thing if (instance.lastState?.device?.userData != null) instance.lastState.device.userData.data ??= data; } Dictionary device = new Dictionary { { "last_online", DateTime.UtcNow.ToLongDateString() }, { "data", data }, }; PostRequestCallback( instance.velConnectUrl + "/device/" + deviceId, JsonConvert.SerializeObject(device, Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }) ); } public static void SetRoomData(string key, string value) { SetRoomData(new Dictionary { { key, value } }); } public static void SetRoomData(Dictionary data) { if (!VelNetManager.InRoom) { Debug.LogError("Can't set data for a room if you're not in a room."); return; } State.DataBlock room = new State.DataBlock { category = "room", data = data }; // remove keys that already match our current state if (instance.state?.room != null) { foreach (string key in data.Keys.ToArray()) { instance.state.room.data.TryGetValue(key, out string currentValue); if (currentValue == data[key]) { data.Remove(key); } } } // if we have no changed values if (data.Keys.Count == 0) { return; } // update our local state, so we don't get change events on our own updates if (instance.lastState?.room != null) { foreach (KeyValuePair kvp in data) { instance.lastState.room.data[kvp.Key] = kvp.Value; } } PostRequestCallback( instance.velConnectUrl + "/data_block/" + Application.productName + "_" + VelNetManager.Room, JsonConvert.SerializeObject(room, Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }) ); } public static void Unpair() { if (instance.state?.device != null) { PostRequestCallback( instance.velConnectUrl + "/unpair", JsonConvert.SerializeObject(new Dictionary() { { "device_id", instance.state.device.id }, { "user_id", instance.state.user.id } }) ); } } // TODO public static void UploadFile(string fileName, byte[] fileData, Action successCallback = null) { // MultipartFormDataContent requestContent = new MultipartFormDataContent(); // ByteArrayContent fileContent = new ByteArrayContent(fileData); // // requestContent.Add(fileContent, "file", fileName); // // Task.Run(async () => // { // HttpResponseMessage r = // await new HttpClient().PostAsync(_instance.velConnectUrl + "/api/upload_file", requestContent); // string resp = await r.Content.ReadAsStringAsync(); // Dictionary dict = JsonConvert.DeserializeObject>(resp); // successCallback?.Invoke(dict["key"]); // }); } // TODO public static void DownloadFile(string key, Action successCallback = null) { // _instance.StartCoroutine(_instance.DownloadFileCo(key, successCallback)); } private IEnumerator DownloadFileCo(string key, Action successCallback = null) { UnityWebRequest www = new UnityWebRequest(velConnectUrl + "/api/download_file/" + key); www.downloadHandler = new DownloadHandlerBuffer(); yield return www.SendWebRequest(); if (www.result != UnityWebRequest.Result.Success) { Debug.Log(www.error); } else { // Show results as text Debug.Log(www.downloadHandler.text); // Or retrieve results as binary data byte[] results = www.downloadHandler.data; successCallback?.Invoke(results); } } public static void GetRequestCallback(string url, Action successCallback = null, Action failureCallback = null) { instance.StartCoroutine(instance.GetRequestCallbackCo(url, successCallback, failureCallback)); } private IEnumerator GetRequestCallbackCo(string url, Action successCallback = null, Action failureCallback = null) { using UnityWebRequest webRequest = UnityWebRequest.Get(url); // Request and wait for the desired page. yield return webRequest.SendWebRequest(); switch (webRequest.result) { case UnityWebRequest.Result.ConnectionError: case UnityWebRequest.Result.DataProcessingError: case UnityWebRequest.Result.ProtocolError: Debug.LogError(url + ": Error: " + webRequest.error + "\n" + Environment.StackTrace); failureCallback?.Invoke(webRequest.error); break; case UnityWebRequest.Result.Success: successCallback?.Invoke(webRequest.downloadHandler.text); break; } } public static void PostRequestCallback(string url, string postData, Dictionary headers = null, Action successCallback = null, Action failureCallback = null) { instance.StartCoroutine(PostRequestCallbackCo(url, postData, headers, successCallback, failureCallback)); } private static IEnumerator PostRequestCallbackCo(string url, string postData, Dictionary headers = null, Action successCallback = null, Action failureCallback = null) { UnityWebRequest webRequest = new UnityWebRequest(url, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(postData); UploadHandlerRaw uploadHandler = new UploadHandlerRaw(bodyRaw); webRequest.uploadHandler = uploadHandler; webRequest.downloadHandler = new DownloadHandlerBuffer(); webRequest.SetRequestHeader("Content-Type", "application/json"); if (headers != null) { foreach (KeyValuePair keyValuePair in headers) { webRequest.SetRequestHeader(keyValuePair.Key, keyValuePair.Value); } } yield return webRequest.SendWebRequest(); switch (webRequest.result) { case UnityWebRequest.Result.ConnectionError: case UnityWebRequest.Result.DataProcessingError: case UnityWebRequest.Result.ProtocolError: Debug.LogError(url + ": Error: " + webRequest.error + "\n" + Environment.StackTrace); failureCallback?.Invoke(webRequest.error); break; case UnityWebRequest.Result.Success: successCallback?.Invoke(webRequest.downloadHandler.text); break; } uploadHandler.Dispose(); webRequest.Dispose(); } private void OnApplicationFocus(bool focus) { UpdateUserCount(!focus); } } }