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;
deviceId = CreateDeviceId();
VelNetManager.OnLocalNetworkObjectSpawned += networkObject =>
{
if (!networkObject.ownershipLocked)
{
// TODO
// SetRoomData("spawned_" + networkObject.networkId, networkObject.prefabName);
}
};
}
// Computes 15-char device id compatibly with pocketbase
private static string CreateDeviceId()
{
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())));
return 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;
if (state.room == 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",
modified_by = "Unity",
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.LogWarning(url + ": Error: " + webRequest.error + "\n" + webRequest.downloadHandler.text + "\n" + Environment.StackTrace);
failureCallback?.Invoke(webRequest.error);
break;
case UnityWebRequest.Result.Success:
successCallback?.Invoke(webRequest.downloadHandler.text);
break;
}
uploadHandler.Dispose();
webRequest.Dispose();
}
public static void SetDataBlock(string blockId, State.DataBlock dataBlock)
{
PostRequestCallback(instance.velConnectUrl + "/data_block/" + blockId, JsonConvert.SerializeObject(dataBlock, Formatting.None, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
}));
}
public static void GetDataBlock(string blockId, Action successCallback = null, Action failureCallback = null)
{
GetRequestCallback(instance.velConnectUrl + "/data_block/" + blockId, data =>
{
State.DataBlock dict = JsonConvert.DeserializeObject(data);
successCallback?.Invoke(dict);
}, failureCallback);
}
private void OnApplicationFocus(bool focus)
{
UpdateUserCount(!focus);
}
}
}