/** * 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 } }