/**
* Copyright 2019 The Knights Of Unity, created by Pawel Stolarczyk
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* 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 System.Collections.Generic;
using UnityEngine;
using Nakama;
using System;
using System.Threading.Tasks;
using Facebook.Unity;
using System.Linq;
using DemoGame.Scripts.Utils;
namespace DemoGame.Scripts.Session
{
///
/// Manages Nakama server interaction and user session throughout the game.
///
///
/// Whenever a user tries to communicate with game server it ensures that their session hasn't expired. If the
/// session is expired the user will have to reauthenticate the session and obtain a new session.
///
public class NakamaSessionManager : Singleton
{
#region Variables
///
/// IP Address of the server.
/// For demonstration purposes, the value is set through Inspector.
///
[SerializeField] private string _ipAddress = "localhost";
///
/// Port behind which Nakama server can be found.
/// The default value is 7350
/// For demonstration purposes, the value is set through Inspector.
///
[SerializeField] private int _port = 7350;
///
/// Cached value of .
/// Used to authenticate this device on Nakama server.
///
private string _deviceId;
///
/// Used to establish connection between the client and the server.
/// Contains a list of usefull methods required to communicate with Nakama server.
/// Do not use this directly, use instead.
///
private Client _client;
///
/// Socket responsible for maintaining connection with Nakama server and exchanger realtime messages.
/// Do not use this directly, use instead.
///
private ISocket _socket;
#region Debug
[Header("Debug")]
///
/// If true, stored session authentication token and device id will be erased on start
///
[SerializeField] private bool _erasePlayerPrefsOnStart = false;
///
/// Sufix added to to generate new device id.
///
[SerializeField] private string _sufix = string.Empty;
#endregion
#endregion
#region Properties
///
/// Used to communicate with Nakama server.
/// For the user to send and receive messages from the server, must not be expired.
/// Default expiration time is 60s, but for this demo we set it to 3 weeks (1 814 400 seconds).
/// To initialize the session, call or methods.
/// To reinitialize expired session, call method.
///
public ISession Session { get; private set; }
///
/// Contains all the identifying data of a , like User Id, linked Device IDs,
/// linked Facebook account, username, etc.
///
public IApiAccount Account { get; private set; }
///
/// Used to establish connection between the client and the server.
/// Contains a list of usefull methods required to communicate with Nakama server.
///
public Client Client
{
get
{
if (_client == null)
{
// "defaultkey" should be changed when releasing the app
// see https://heroiclabs.com/docs/install-configuration/#socket
_client = new Client("defaultkey", _ipAddress, _port, false);
}
return _client;
}
}
///
/// Socket responsible for maintaining connection with Nakama server and exchange realtime messages.
///
public ISocket Socket
{
get
{
if (_socket == null)
{
// Initializing socket
_socket = Client.CreateWebSocket();
}
return _socket;
}
}
///
/// Returns true if between this device and Nakama server exists.
///
public bool IsConnected
{
get
{
if (Session == null || Session.HasExpired(DateTime.UtcNow) == true)
{
return false;
}
else
{
return true;
}
}
}
#endregion
#region Events
///
/// Invoked whenever client first authorizes using DeviceId.
///
public event Action OnConnectionSuccess = delegate { Debug.Log(">> Connection Success"); };
///
/// Invoked whenever client first authorizes using DeviceId.
///
public event Action OnNewAccountCreated = delegate { Debug.Log(">> New Account Created"); };
///
/// Invoked upon DeviceId authorisation failure.
///
public event Action OnConnectionFailure = delegate { Debug.Log(">> Connection Error"); };
///
/// Invoked after is called.
///
public event Action OnDisconnected = delegate { Debug.Log(">> Disconnected"); };
#endregion
#region Mono
///
/// Creates new object used to communicate with Nakama server.
/// Authenticates this device using its or
/// using Facebook account, if is true.
///
private void Start()
{
DontDestroyOnLoad(gameObject);
if (_erasePlayerPrefsOnStart == true)
{
PlayerPrefs.SetString("nakama.authToken", "");
PlayerPrefs.SetString("nakama.deviceId", "");
}
GetDeviceId();
//await ConnectAsync();
}
///
/// Closes Nakama session.
///
protected override void OnDestroy()
{
Disconnect();
}
#endregion
#region Authentication
public void SetIp(string ip)
{
if (IsConnected == false)
{
_ipAddress = ip;
}
}
///
/// Restores session or tries to establish a new one.
/// Invokes or .
///
///
public async Task ConnectAsync()
{
AuthenticationResponse response = await RestoreTokenAsync();
switch (response)
{
case AuthenticationResponse.Authenticated:
OnConnectionSuccess?.Invoke();
break;
case AuthenticationResponse.NewAccountCreated:
OnNewAccountCreated?.Invoke();
OnConnectionSuccess?.Invoke();
break;
case AuthenticationResponse.Error:
OnConnectionFailure?.Invoke();
break;
default:
Debug.LogError("Unhandled response received: " + response);
break;
}
return response;
}
///
/// Restores saved Session Athentication Token if user has already authenticated with the server in the past.
/// If it's the first time authenticating using this device id, a new account will be created.
///
private async Task RestoreTokenAsync()
{
// Restoring authentication token from player prefs
string authToken = PlayerPrefs.GetString("nakama.authToken", null);
if (string.IsNullOrWhiteSpace(authToken) == true)
{
// Token not found
// Authenticating new session
return await AuthenticateAsync();
}
else
{
// Restoring previous session
Session = Nakama.Session.Restore(authToken);
if (Session.HasExpired(DateTime.UtcNow) == true)
{
// Restored session has expired
// Authenticating new session
return await AuthenticateAsync();
}
else
{
// Session restored
// Getting Account info
Account = await GetAccountAsync();
if (Account == null)
{
// Account not found
// Creating new account
return await AuthenticateAsync();
}
// Creating real-time communication socket
bool socketConnected = await ConnectSocketAsync();
if (socketConnected == false)
{
return AuthenticationResponse.Error;
}
Debug.Log("Session restored with token:" + Session.AuthToken);
return AuthenticationResponse.Authenticated;
}
}
}
///
/// This method authenticates this device using local and initializes new session
/// with Nakama server. If it's the first time user logs in using this device, a new account will be created
/// (calling ). Upon successfull authentication, Account data is retrieved
/// and real-time communication socket is connected.
///
/// Returns true if every server call was successful.
private async Task AuthenticateAsync()
{
AuthenticationResponse response = await AuthenticateDeviceIdAsync();
if (response == AuthenticationResponse.Error)
{
return AuthenticationResponse.Error;
}
Account = await GetAccountAsync();
if (Account == null)
{
return AuthenticationResponse.Error;
}
bool socketConnected = await ConnectSocketAsync();
if (socketConnected == false)
{
return AuthenticationResponse.Error;
}
StoreSessionToken();
return response;
}
///
/// Authenticates a new session using DeviceId. If it's the first time authenticating using
/// this device, new account is created.
///
/// Returns true if every server call was successful.
private async Task AuthenticateDeviceIdAsync()
{
try
{
Session = await Client.AuthenticateDeviceAsync(_deviceId, null, false);
Debug.Log("Device authenticated with token:" + Session.AuthToken);
return AuthenticationResponse.Authenticated;
}
catch (ApiResponseException e)
{
if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
{
Debug.Log("Couldn't find DeviceId in database, creating new user; message: " + e);
return await CreateAccountAsync();
}
else
{
Debug.LogError("An error has occured reaching Nakama server; message: " + e);
return AuthenticationResponse.Error;
}
}
catch (Exception e)
{
Debug.LogError("Counldn't connect to Nakama server; message: " + e);
return AuthenticationResponse.Error;
}
}
///
/// Creates new account on Nakama server using local .
///
/// Returns true if account was successfully created.
private async Task CreateAccountAsync()
{
try
{
Session = await Client.AuthenticateDeviceAsync(_deviceId, null, true);
return AuthenticationResponse.NewAccountCreated;
}
catch (Exception e)
{
Debug.LogError("Couldn't create account using DeviceId; message: " + e);
return AuthenticationResponse.Error;
}
}
///
/// Connects to Nakama server to enable real-time communication.
///
/// Returns true if socket has connected successfully.
private async Task ConnectSocketAsync()
{
try
{
if (_socket != null)
{
await _socket.DisconnectAsync();
}
}
catch (Exception e)
{
Debug.LogWarning("Couldn't disconnect the socket: " + e);
}
try
{
await Socket.ConnectAsync(Session);
return true;
}
catch (Exception e)
{
Debug.LogError("An error has occured while connecting socket: " + e);
return false;
}
}
///
/// Removes session and account from cache, logs out of Facebook and invokes .
///
public void Disconnect()
{
if (FB.IsLoggedIn == true)
{
FB.LogOut();
Debug.Log("Disconnected from Facebook");
}
if (Session == null)
{
return;
}
else
{
Session = null;
Account = null;
Debug.Log("Disconnected from Nakama");
OnDisconnected.Invoke();
}
}
#endregion
#region UserInfo
///
/// Receives currently logged in user's from server.
///
public async Task GetAccountAsync()
{
try
{
IApiAccount results = await Client.GetAccountAsync(Session);
return results;
}
catch (Exception e)
{
Debug.LogWarning("An error has occured while retrieving account: " + e);
return null;
}
}
///
/// Receives info from server using user id or username.
/// Either or must not be null.
///
public async Task GetUserInfoAsync(string userId, string username)
{
try
{
IApiUsers results = await Client.GetUsersAsync(Session, new string[] { userId }, new string[] { username });
if (results.Users.Count() != 0)
{
return results.Users.ElementAt(0);
}
else
{
Debug.LogWarning("Couldn't find user with id: " + userId);
return null;
}
}
catch (System.Exception e)
{
Debug.LogWarning("An error has occured while retrieving user info: " + e);
return null;
}
}
///
/// Async method used to update user's username and avatar url.
///
public async Task UpdateUserInfoAsync(string username, string avatarUrl)
{
try
{
await Client.UpdateAccountAsync(Session, username, null, avatarUrl);
// Terminate current session and log in using new username
PlayerPrefs.SetString("nakama.authToken", "");
AuthenticationResponse response = await ConnectAsync();
return response;
}
catch (ApiResponseException e)
{
Debug.LogError("Couldn't update user info with code " + e.StatusCode + ": " + e);
return AuthenticationResponse.Error;
}
catch (Exception e)
{
Debug.LogError("Couldn't update user info: " + e);
return AuthenticationResponse.Error;
}
}
///
/// Retrieves device id from player prefs. If it's the first time running this app
/// on this device, is filled with .
///
private void GetDeviceId()
{
if (string.IsNullOrEmpty(_deviceId) == true)
{
_deviceId = PlayerPrefs.GetString("nakama.deviceId");
if (string.IsNullOrWhiteSpace(_deviceId) == true)
{
_deviceId = SystemInfo.deviceUniqueIdentifier;
PlayerPrefs.SetString("nakama.deviceId", _deviceId);
}
_deviceId += _sufix;
}
}
///
/// Stores Nakama session authentication token in player prefs
///
private void StoreSessionToken()
{
if (Session == null)
{
Debug.LogWarning("Session is null; cannot store in player prefs");
}
else
{
PlayerPrefs.SetString("nakama.authToken", Session.AuthToken);
}
}
#endregion
#region Facebook
///
/// Initializes Facebook connection.
///
/// Invoked after Facebook authorisation.
public void ConnectFacebook(Action handler)
{
if (FB.IsInitialized == false)
{
FB.Init(() => InitializeFacebook(handler));
}
else
{
InitializeFacebook(handler);
}
}
///
/// Invoked by callback.
/// Tries to log in using Facebook account and authenticates user with Nakama server.
///
/// Invoked when Facebook authorisation was successful.
/// Invoked when Facebook authorisation failed.
private void InitializeFacebook(Action handler)
{
FB.ActivateApp();
List permissions = new List();
permissions.Add("public_profile");
FB.LogInWithReadPermissions(permissions, async result =>
{
FacebookResponse response = await ConnectFacebookAsync(result);
handler?.Invoke(response);
});
}
///
/// Connects Facebook to currently logged in Nakama account.
///
private async Task ConnectFacebookAsync(ILoginResult result)
{
FacebookResponse response = await LinkFacebookAsync(result);
if (response != FacebookResponse.Linked)
{
return response;
}
Account = await GetAccountAsync();
if (Account == null)
{
return FacebookResponse.Error;
}
bool socketConnected = await ConnectSocketAsync();
if (socketConnected == false)
{
return FacebookResponse.Error;
}
StoreSessionToken();
return FacebookResponse.Linked;
}
///
/// Tries to authenticate this user using Facebook account. If used facebook account hasn't been found in Nakama
/// database, creates new Nakama user account and asks user if they want to transfer their progress, otherwise
/// connects to account linked with supplied Facebook account.
///
private async Task LinkFacebookAsync(ILoginResult result = null)
{
if (FB.IsLoggedIn == true)
{
string token = AccessToken.CurrentAccessToken.TokenString;
try
{
await Client.LinkFacebookAsync(Session, token, true);
return FacebookResponse.Linked;
}
catch (ApiResponseException e)
{
if (e.StatusCode == System.Net.HttpStatusCode.Conflict)
{
return FacebookResponse.Conflict;
}
else
{
Debug.LogWarning("An error has occured reaching Nakama server; message: " + e);
return FacebookResponse.Error;
}
}
catch (Exception e)
{
Debug.LogWarning("An error has occured while connection with Facebook; message: " + e);
return FacebookResponse.Error;
}
}
else
{
if (result == null)
{
Debug.Log("Facebook not logged in. Call ConnectFacebook first");
return FacebookResponse.NotInitialized;
}
else if (result.Cancelled == true)
{
Debug.Log("Facebook login canceled");
return FacebookResponse.Cancelled;
}
else if (string.IsNullOrWhiteSpace(result.Error) == false)
{
Debug.Log("Facebook login failed with error: " + result.Error);
return FacebookResponse.Error;
}
else
{
Debug.Log("Facebook login failed with no error message");
return FacebookResponse.Error;
}
}
}
///
/// Transfers this Device Id to an already existing user account linked with Facebook.
/// This will leave current account floating, with no real device linked to it.
///
public async Task MigrateDeviceIdAsync(string facebookToken)
{
try
{
Debug.Log("Starting account migration");
string dummyGuid = _deviceId + "-";
await Client.LinkDeviceAsync(Session, dummyGuid);
Debug.Log("Dummy id linked");
ISession activatedSession = await Client.AuthenticateFacebookAsync(facebookToken, null, false);
Debug.Log("Facebook authenticated");
await Client.UnlinkDeviceAsync(Session, _deviceId);
Debug.Log("Local id unlinked");
await Client.LinkDeviceAsync(activatedSession, _deviceId);
Debug.Log("Local id linked. Migration successfull");
Session = activatedSession;
StoreSessionToken();
Account = await GetAccountAsync();
if (Account == null)
{
throw new Exception("Couldn't retrieve linked account data");
}
return true;
}
catch (Exception e)
{
Debug.LogWarning("An error has occured while linking dummy guid to local account: " + e);
return false;
}
}
#endregion
}
}