push notifications

This commit is contained in:
2026-11-03 20:26:24 +05:30
parent a1b2b41912
commit 3ee348c09f
221 changed files with 9440 additions and 4899 deletions
+293 -9
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using DG.Tweening;
using TMPro;
using UnityEngine;
@@ -10,6 +11,8 @@ public class CustomerSupportCanvas : MonoBehaviour
{
const float ScreenFadeSeconds = 0.5f;
const float PollIntervalSeconds = 5f;
const float ChatLayoutRebuildDelay = 0.1f;
const int ChatWrapEveryChars = 25;
static readonly (string slug, string label)[] Categories =
{
@@ -42,6 +45,10 @@ public class CustomerSupportCanvas : MonoBehaviour
public GameObject noChatSelectedTxt;
public RectTransform ticketChatParent;
/// <summary>
/// ScrollRect that wraps <see cref="ticketChatParent"/>. If unset, resolved from the chat parent.
/// </summary>
public ScrollRect ticketChatScroll;
/// <summary>
/// Two children, first is left, second is right, only 1 TMP_Text inside each, which should be the message. enable left for customer support agent, enable right for player.
/// </summary>
public GameObject ticketChatItemPrefab;
@@ -63,15 +70,31 @@ public class CustomerSupportCanvas : MonoBehaviour
readonly List<Toggle> _reasonToggles = new List<Toggle>();
readonly List<string> _reasonSlugs = new List<string>();
public static CustomerSupportCanvas instance;
string _selectedTicketId;
string _selectedCategorySlug;
string _selectedCategoryLabel;
string _pushTicketId;
Coroutine _pollRoutine;
Coroutine _busyRoutine;
Coroutine _chatLayoutRoutine;
string _chatFingerprint;
bool _requestInFlight;
bool _reasonsBuilt;
ToggleGroup _reasonToggleGroup;
void Awake()
{
instance = this;
}
void OnDestroy()
{
if (instance == this)
instance = null;
}
void Start()
{
if (btnFaq != null)
@@ -91,16 +114,79 @@ public class CustomerSupportCanvas : MonoBehaviour
if (btnCancelTicket != null)
btnCancelTicket.onClick.AddListener(CloseNewTicketPanel);
SetCanvasGroupVisible(helpScreen, true, false);
SetCanvasGroupVisible(helpScreen, false, false);
SetCanvasGroupVisible(ticketScreen, false, false);
ClearChatSelection();
HideNewTicketPanel();
EnsureReasonToggles();
string pendingTicket = PushNotificationService.ConsumePendingTicket();
if (!string.IsNullOrEmpty(pendingTicket))
OpenFromNotification(pendingTicket);
}
/// <summary>
/// Opens the ticket named by a support push. A reply for the thread already on screen refreshes it immediately.
/// </summary>
public void OpenFromNotification(string ticketId)
{
if (string.IsNullOrEmpty(ticketId))
return;
bool ticketVisible = ticketScreen != null && ticketScreen.blocksRaycasts;
if (ticketVisible && _selectedTicketId == ticketId)
{
StartCoroutine(RefreshOpenThread(ticketId));
return;
}
if (_requestInFlight)
{
_pushTicketId = ticketId;
return;
}
RunExclusive(ShowTicketFromNotification(ticketId));
}
IEnumerator ShowTicketFromNotification(string ticketId)
{
HideNewTicketPanel();
SetCanvasGroupVisible(helpScreen, false, true);
SetCanvasGroupVisible(ticketScreen, true, true);
bool ok = false;
yield return EnsureSupportAuth(r => ok = r);
if (!ok)
yield break;
SupportCallResult<SupportTicketListResponse> list = null;
yield return SupportClient.ListTickets(r => list = r);
if (list != null && list.ok)
RebuildTicketList(list.data != null ? list.data.tickets : null);
if (_selectedTicketId == ticketId)
yield return RefreshOpenThread(ticketId);
else
SelectTicket(ticketId);
}
IEnumerator RefreshOpenThread(string ticketId)
{
SupportCallResult<SupportThread> result = null;
yield return SupportClient.GetThread(ticketId, r => result = r);
if (result == null || !result.ok)
yield break;
if (_selectedTicketId != ticketId)
yield break;
ApplyThread(result.data);
RefreshTicketPreviewFromThread(result.data);
}
void OnDisable()
{
StopPoll();
StopChatLayoutRoutine();
if (_busyRoutine != null)
{
StopCoroutine(_busyRoutine);
@@ -134,7 +220,6 @@ public class CustomerSupportCanvas : MonoBehaviour
HideNewTicketPanel();
SetCanvasGroupVisible(helpScreen, false, true);
SetCanvasGroupVisible(ticketScreen, false, true);
gameObject.SetActive(false);
}
void LaunchFAQ()
@@ -237,7 +322,7 @@ public class CustomerSupportCanvas : MonoBehaviour
yield break;
SupportCallResult<SupportThread> result = null;
yield return SupportClient.CreateTicket(category, body, r => result = r);
yield return SupportClient.CreateTicket(category, body, SupportClient.ReadUnityPlayerLog(), r => result = r);
if (result == null || !result.ok)
{
ShowError(result != null ? result.error : "Could not create ticket.");
@@ -273,8 +358,6 @@ public class CustomerSupportCanvas : MonoBehaviour
? LoginManager.SessionUsername
: (LoginManager.CurrentUser != null ? LoginManager.CurrentUser.username : null);
string password = LoginManager.SessionPassword;
Debug.Log("[CustomerSupport] support login as user='" + (username ?? "") +
"' passwordLength=" + (password != null ? password.Length : 0));
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
ShowError("Please sign in again to use customer support.");
@@ -345,9 +428,15 @@ public class CustomerSupportCanvas : MonoBehaviour
void SetChatInputVisible(bool visible)
{
if (inputField != null)
{
inputField.gameObject.SetActive(visible);
inputField.interactable = visible && !_requestInFlight;
}
if (btnSend != null)
{
btnSend.gameObject.SetActive(visible);
btnSend.interactable = visible && !_requestInFlight && !string.IsNullOrEmpty(_selectedTicketId);
}
}
void RebuildTicketList(SupportTicket[] tickets)
@@ -385,7 +474,12 @@ public class CustomerSupportCanvas : MonoBehaviour
TMP_Text label = item.GetComponentInChildren<TMP_Text>(true);
if (label != null)
label.text = "Ticket #" + (i + 1).ToString("000");
{
string title = "Ticket #" + (i + 1).ToString("000");
if (!string.IsNullOrEmpty(ticket.categoryLabel))
title += "\n" + ticket.categoryLabel;
label.text = title;
}
Image image = item.GetComponent<Image>();
if (image == null)
@@ -419,7 +513,13 @@ public class CustomerSupportCanvas : MonoBehaviour
if (thread == null)
return;
ClearChatMessages();
string fingerprint = BuildChatFingerprint(thread);
if (fingerprint == _chatFingerprint && ticketChatParent != null && ticketChatParent.childCount > 0)
return;
StopChatLayoutRoutine();
ClearChildren(ticketChatParent);
_chatFingerprint = fingerprint;
if (ticketChatItemPrefab == null || ticketChatParent == null || thread.messages == null)
return;
@@ -446,16 +546,190 @@ public class CustomerSupportCanvas : MonoBehaviour
{
TMP_Text text = activeSide.GetComponentInChildren<TMP_Text>(true);
if (text != null)
text.text = message.body ?? "";
text.text = FormatChatBody(message.body);
}
}
ScheduleChatLayoutRebuildAndScroll();
}
void ClearChatMessages()
{
StopChatLayoutRoutine();
_chatFingerprint = null;
ClearChildren(ticketChatParent);
}
void ScheduleChatLayoutRebuildAndScroll()
{
StopChatLayoutRoutine();
if (!isActiveAndEnabled || ticketChatParent == null)
return;
_chatLayoutRoutine = StartCoroutine(ChatLayoutRebuildAndScrollCoroutine());
}
IEnumerator ChatLayoutRebuildAndScrollCoroutine()
{
yield return new WaitForSecondsRealtime(ChatLayoutRebuildDelay);
if (ticketChatParent == null)
yield break;
RebuildChatItemLayouts();
AppendTrailingSpaceToChatItems();
RebuildChatItemLayouts();
ScrollChatToBottom();
_chatLayoutRoutine = null;
}
void RebuildChatItemLayouts()
{
if (ticketChatParent == null)
return;
for (int i = 0; i < ticketChatParent.childCount; i++)
{
var item = ticketChatParent.GetChild(i) as RectTransform;
if (item == null)
continue;
LayoutRebuilder.ForceRebuildLayoutImmediate(item);
}
LayoutRebuilder.ForceRebuildLayoutImmediate(ticketChatParent);
}
void AppendTrailingSpaceToChatItems()
{
if (ticketChatParent == null)
return;
for (int i = 0; i < ticketChatParent.childCount; i++)
{
Transform item = ticketChatParent.GetChild(i);
TMP_Text text = FindActiveChatText(item);
if (text == null)
continue;
if (!text.text.EndsWith(" ", StringComparison.Ordinal))
text.text += " ";
}
}
static TMP_Text FindActiveChatText(Transform item)
{
if (item == null)
return null;
for (int i = 0; i < item.childCount; i++)
{
Transform side = item.GetChild(i);
if (side == null || !side.gameObject.activeSelf)
continue;
TMP_Text text = side.GetComponentInChildren<TMP_Text>(true);
if (text != null)
return text;
}
return item.GetComponentInChildren<TMP_Text>(true);
}
static string FormatChatBody(string body)
{
if (string.IsNullOrEmpty(body))
return "";
var sb = new StringBuilder(body.Length + body.Length / ChatWrapEveryChars);
int col = 0;
int i = 0;
while (i < body.Length)
{
char c = body[i];
if (c == '\n')
{
sb.Append('\n');
col = 0;
i++;
continue;
}
if (char.IsWhiteSpace(c))
{
while (i < body.Length && char.IsWhiteSpace(body[i]) && body[i] != '\n')
i++;
continue;
}
int start = i;
while (i < body.Length && !char.IsWhiteSpace(body[i]) && body[i] != '\n')
i++;
int wordLen = i - start;
if (col > 0 && col + 1 + wordLen > ChatWrapEveryChars)
{
sb.Append('\n');
col = 0;
}
else if (col > 0)
{
sb.Append(' ');
col++;
}
if (wordLen > ChatWrapEveryChars)
{
for (int w = 0; w < wordLen; w++)
{
if (col >= ChatWrapEveryChars)
{
sb.Append('\n');
col = 0;
}
sb.Append(body[start + w]);
col++;
}
}
else
{
sb.Append(body, start, wordLen);
col += wordLen;
}
}
return sb.ToString();
}
void ScrollChatToBottom()
{
ScrollRect scroll = ticketChatScroll;
if (scroll == null && ticketChatParent != null)
scroll = ticketChatParent.GetComponentInParent<ScrollRect>();
if (scroll == null)
return;
Canvas.ForceUpdateCanvases();
scroll.verticalNormalizedPosition = 0f;
if (scroll.content != null)
LayoutRebuilder.ForceRebuildLayoutImmediate(scroll.content);
Canvas.ForceUpdateCanvases();
scroll.verticalNormalizedPosition = 0f;
}
void StopChatLayoutRoutine()
{
if (_chatLayoutRoutine == null)
return;
StopCoroutine(_chatLayoutRoutine);
_chatLayoutRoutine = null;
}
static string BuildChatFingerprint(SupportThread thread)
{
if (thread == null || thread.messages == null || thread.messages.Length == 0)
return "0";
SupportMessage last = thread.messages[thread.messages.Length - 1];
string lastId = last != null ? last.id : "";
return thread.messages.Length + ":" + lastId;
}
void RefreshTicketPreviewFromThread(SupportThread thread)
{
if (thread == null || thread.ticket == null || string.IsNullOrEmpty(thread.ticket.id))
@@ -596,13 +870,23 @@ public class CustomerSupportCanvas : MonoBehaviour
_requestInFlight = false;
_busyRoutine = null;
SetBusyUi(false);
if (!string.IsNullOrEmpty(_pushTicketId))
{
string ticketId = _pushTicketId;
_pushTicketId = null;
if (isActiveAndEnabled)
OpenFromNotification(ticketId);
}
}
}
void SetBusyUi(bool busy)
{
bool chatReady = !busy && !string.IsNullOrEmpty(_selectedTicketId);
if (btnSend != null)
btnSend.interactable = !busy && !string.IsNullOrEmpty(_selectedTicketId);
btnSend.interactable = chatReady;
if (inputField != null)
inputField.interactable = chatReady;
if (newTicketBtn != null)
newTicketBtn.interactable = !busy;
if (btnTickets != null)
+10
View File
@@ -145,6 +145,7 @@ public class LoginManager : MonoBehaviour
else
StartKeepalive();
#endif
PushNotificationService.EnsureCreated();
}
void Start()
@@ -238,7 +239,10 @@ public class LoginManager : MonoBehaviour
}, true));
if (ok)
{
PushNotificationService.OnGameSessionReady();
ProceedToMainMenu();
}
else
{
InvalidateStoredSession();
@@ -310,6 +314,11 @@ public class LoginManager : MonoBehaviour
/// <summary>Full logout, then reload the login scene so a fresh <see cref="LoginManager"/> can bind UI.</summary>
public static void LogoutAndReturnToLogin()
{
PushNotificationService.UnregisterThen(FinishLogoutAndReturnToLogin);
}
static void FinishLogoutAndReturnToLogin()
{
Logout();
AddressableSceneLoader.Load(AddressableSceneLoader.IntroScene);
@@ -735,6 +744,7 @@ public class LoginManager : MonoBehaviour
_pendingSessionPassword = null;
enteredMenu = true;
StartKeepalive();
PushNotificationService.OnGameSessionReady();
ProceedToMainMenu();
}
else
+405
View File
@@ -0,0 +1,405 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_ANDROID && !UNITY_EDITOR
using Firebase.Extensions;
using Firebase.Messaging;
#endif
/// <summary>
/// Android FCM registration for the player client. The Editor and dedicated server builds do not talk to Firebase.
/// </summary>
public class PushNotificationService : MonoBehaviour
{
public const string BroadcastTopic = "kk_broadcast";
public const string ChannelId = "support";
public static PushNotificationService instance { get; private set; }
static string _pendingTicketId;
readonly List<Action> _mainThread = new List<Action>();
readonly object _mainLock = new object();
#if UNITY_ANDROID && !UNITY_EDITOR
const string RegisteredFidKey = "WalkInvest.SupportPushFid";
const string RegisteredUserKey = "WalkInvest.SupportPushUserId";
const string PostNotificationsPermission = "android.permission.POST_NOTIFICATIONS";
const float UnregisterTimeoutSeconds = 5f;
string _fid;
string _lastRegisteredFid;
int _lastRegisteredUserId;
bool _sessionReady;
bool _registerQueued;
bool _registerInFlight;
bool _topicSubscribed;
bool _subscribeInFlight;
bool _firebaseReady;
Firebase.FirebaseApp _firebaseApp;
#endif
public static void EnsureCreated()
{
#if UNITY_ANDROID && !UNITY_EDITOR
if (instance != null)
return;
var go = new GameObject(nameof(PushNotificationService));
go.AddComponent<PushNotificationService>();
#endif
}
public static void OnGameSessionReady()
{
#if UNITY_ANDROID && !UNITY_EDITOR
EnsureCreated();
if (instance == null)
return;
instance._sessionReady = true;
instance.RequestRegister();
#endif
}
/// <summary>
/// Deletes this install on the admin API, then runs <paramref name="next"/>.
/// A failed or timed-out delete still continues.
/// </summary>
public static void UnregisterThen(Action next)
{
#if UNITY_ANDROID && !UNITY_EDITOR
if (instance != null && instance.CanUnregister)
{
instance.StartCoroutine(instance.UnregisterAndContinue(next));
return;
}
if (instance != null)
instance.ForgetLocalRegistration();
#endif
next?.Invoke();
}
public static string ConsumePendingTicket()
{
string ticketId = _pendingTicketId;
_pendingTicketId = null;
return ticketId;
}
void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this;
DontDestroyOnLoad(gameObject);
#if UNITY_ANDROID && !UNITY_EDITOR
_lastRegisteredFid = PlayerPrefs.GetString(RegisteredFidKey, "");
_lastRegisteredUserId = PlayerPrefs.GetInt(RegisteredUserKey, 0);
InitializeFirebase();
#endif
}
void OnDestroy()
{
if (instance == this)
instance = null;
#if UNITY_ANDROID && !UNITY_EDITOR
if (_firebaseReady)
{
FirebaseMessaging.RegistrationReceived -= OnRegistrationReceived;
FirebaseMessaging.MessageReceived -= OnMessageReceived;
}
#endif
}
void Update()
{
Action[] batch = null;
lock (_mainLock)
{
if (_mainThread.Count == 0)
return;
batch = _mainThread.ToArray();
_mainThread.Clear();
}
for (int i = 0; i < batch.Length; i++)
batch[i]?.Invoke();
}
#if UNITY_ANDROID && !UNITY_EDITOR
bool CanUnregister
{
get { return _sessionReady && !string.IsNullOrEmpty(_fid) && SupportClient.IsSignedIn; }
}
void InitializeFirebase()
{
Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task =>
{
if (instance != this)
return;
if (task.IsFaulted || task.IsCanceled)
{
Logger.Log("Firebase dependency check failed.");
return;
}
var status = task.Result;
if (status != Firebase.DependencyStatus.Available)
{
Logger.Log("Firebase dependencies unavailable: " + status);
return;
}
_firebaseApp = Firebase.FirebaseApp.DefaultInstance;
if (_firebaseApp == null)
{
Logger.Log("Firebase app missing.");
return;
}
_firebaseReady = true;
EnsureNotificationChannel();
RequestNotificationPermission();
FirebaseMessaging.RegistrationReceived += OnRegistrationReceived;
FirebaseMessaging.MessageReceived += OnMessageReceived;
FirebaseMessaging.RegisterAsync().ContinueWithOnMainThread(register =>
{
if (instance != this)
return;
if (register.IsFaulted)
Logger.Log("FCM register failed: " + register.Exception.GetBaseException().Message);
});
});
}
void OnRegistrationReceived(object sender, RegistrationReceivedEventArgs args)
{
string fid = args != null ? args.InstallationId : null;
RunOnMain(() => HandleRegistration(fid));
}
void OnMessageReceived(object sender, MessageReceivedEventArgs args)
{
string type = null;
string ticketId = null;
if (args != null && args.Message != null && args.Message.Data != null)
{
args.Message.Data.TryGetValue("type", out type);
args.Message.Data.TryGetValue("ticketId", out ticketId);
}
string typeCopy = type;
string ticketCopy = ticketId;
RunOnMain(() => HandleMessage(typeCopy, ticketCopy));
}
void HandleRegistration(string fid)
{
if (string.IsNullOrEmpty(fid))
return;
if (fid != _fid)
{
_topicSubscribed = false;
_subscribeInFlight = false;
}
_fid = fid;
if (!_topicSubscribed && !_subscribeInFlight)
{
_subscribeInFlight = true;
StartCoroutine(SubscribeBroadcast());
}
RequestRegister();
}
void HandleMessage(string type, string ticketId)
{
if (!string.Equals(type, "support_reply", StringComparison.Ordinal))
return;
if (string.IsNullOrEmpty(ticketId))
return;
CustomerSupportCanvas canvas = CustomerSupportCanvas.instance;
if (canvas != null && canvas.isActiveAndEnabled)
canvas.OpenFromNotification(ticketId);
else
_pendingTicketId = ticketId;
}
IEnumerator SubscribeBroadcast()
{
var task = FirebaseMessaging.SubscribeAsync(BroadcastTopic);
while (!task.IsCompleted)
yield return null;
_subscribeInFlight = false;
if (task.IsFaulted || task.IsCanceled)
{
Logger.Log("FCM topic subscribe failed.");
yield break;
}
_topicSubscribed = true;
}
void RequestRegister()
{
if (!_sessionReady || string.IsNullOrEmpty(_fid))
return;
_registerQueued = true;
if (!_registerInFlight)
StartCoroutine(RegisterLoop());
}
IEnumerator RegisterLoop()
{
_registerInFlight = true;
while (_registerQueued && _sessionReady)
{
_registerQueued = false;
yield return RegisterDeviceBody();
}
_registerInFlight = false;
}
IEnumerator RegisterDeviceBody()
{
if (!_sessionReady || string.IsNullOrEmpty(_fid))
yield break;
if (!SupportClient.IsSignedIn)
{
string username = LoginManager.SessionUsername;
string password = LoginManager.SessionPassword;
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
Logger.Log("Push registration skipped: support session is not available.");
yield break;
}
SupportCallResult<SupportLoginResponse> login = null;
yield return SupportClient.Login(username, password, r => login = r);
if (!_sessionReady)
yield break;
if (login == null || !login.ok)
{
Logger.Log("Push registration skipped: support login failed.");
yield break;
}
}
int userId = SupportClient.UserId;
if (_fid == _lastRegisteredFid && userId != 0 && userId == _lastRegisteredUserId)
yield break;
string fid = _fid;
SupportCallResult<SupportDeviceResponse> result = null;
yield return SupportClient.RegisterDevice(fid, "android", r => result = r);
if (!_sessionReady || fid != _fid)
yield break;
if (result == null || !result.ok)
{
Logger.Log("Push registration failed: " + (result != null ? result.error : "no result"));
yield break;
}
_lastRegisteredFid = fid;
_lastRegisteredUserId = SupportClient.UserId;
PlayerPrefs.SetString(RegisteredFidKey, _lastRegisteredFid ?? "");
PlayerPrefs.SetInt(RegisteredUserKey, _lastRegisteredUserId);
PlayerPrefs.Save();
}
IEnumerator UnregisterAndContinue(Action next)
{
_sessionReady = false;
bool done = false;
StartCoroutine(FinishUnregister(_fid, () => done = true));
float start = Time.unscaledTime;
while (!done && Time.unscaledTime - start < UnregisterTimeoutSeconds)
yield return null;
if (!done)
Logger.Log("Push unregister timed out.");
ForgetLocalRegistration();
next?.Invoke();
}
IEnumerator FinishUnregister(string fid, Action done)
{
SupportCallResult<SupportDeviceResponse> result = null;
yield return SupportClient.UnregisterDevice(fid, r => result = r);
if (result == null || !result.ok)
Logger.Log("Push unregister failed: " + (result != null ? result.error : "no result"));
done?.Invoke();
}
void ForgetLocalRegistration()
{
_lastRegisteredFid = null;
_lastRegisteredUserId = 0;
PlayerPrefs.DeleteKey(RegisteredFidKey);
PlayerPrefs.DeleteKey(RegisteredUserKey);
PlayerPrefs.Save();
}
void RunOnMain(Action action)
{
if (action == null)
return;
lock (_mainLock)
_mainThread.Add(action);
}
static void RequestNotificationPermission()
{
try
{
using (var version = new AndroidJavaClass("android.os.Build$VERSION"))
{
if (version.GetStatic<int>("SDK_INT") < 33)
return;
}
if (!UnityEngine.Android.Permission.HasUserAuthorizedPermission(PostNotificationsPermission))
UnityEngine.Android.Permission.RequestUserPermission(PostNotificationsPermission);
}
catch (Exception e)
{
Logger.Log("Notification permission request failed: " + e.Message);
}
}
static void EnsureNotificationChannel()
{
try
{
using (var version = new AndroidJavaClass("android.os.Build$VERSION"))
{
if (version.GetStatic<int>("SDK_INT") < 26)
return;
}
using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
using (var channel = new AndroidJavaObject("android.app.NotificationChannel", ChannelId, "Support", 4))
using (var manager = activity.Call<AndroidJavaObject>("getSystemService", "notification"))
{
channel.Call("setDescription", "Support replies and announcements");
manager.Call("createNotificationChannel", channel);
}
}
catch (Exception e)
{
Logger.Log("Notification channel setup failed: " + e.Message);
}
}
#endif
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f6b03dba6bc16c1a90c45ab290b9cbb
+146 -13
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
@@ -13,11 +14,13 @@ public static class SupportClient
{
public const string DefaultBaseUrl = "https://kkadmin.playpoolstudios.com";
public const int MaxMessageLength = 4000;
public const int MaxLogsLength = 2000000;
const string TokenPrefsKey = "WalkInvest.SupportPlayerToken";
const string UserIdPrefsKey = "WalkInvest.SupportPlayerUserId";
const string UsernamePrefsKey = "WalkInvest.SupportPlayerUsername";
const string MessageLengthError = "Message must be between 1 and 4000 characters";
const string LogsLengthError = "Logs must be at most 2000000 characters";
const string PlayerConnectionError = "Please check your internet connection and try again.";
const int RequestTimeoutSeconds = 20;
@@ -141,8 +144,6 @@ public static class SupportClient
password = password ?? ""
};
string json = JsonUtility.ToJson(body);
Debug.Log("[SupportClient] login user='" + body.username +
"' passwordLength=" + body.password.Length);
HttpOutcome http = null;
yield return Request("POST", "/api/support/player/login", json, false, r => http = r);
@@ -157,14 +158,55 @@ public static class SupportClient
onComplete?.Invoke(result);
}
/// <summary>POST /api/support/player/devices. Stores this install's Firebase Installation ID.</summary>
public static IEnumerator RegisterDevice(string fid, string platform, Action<SupportCallResult<SupportDeviceResponse>> onComplete)
{
yield return SendDevice("POST", fid, platform, onComplete);
}
/// <summary>DELETE /api/support/player/devices. Drops this install's Firebase Installation ID.</summary>
public static IEnumerator UnregisterDevice(string fid, Action<SupportCallResult<SupportDeviceResponse>> onComplete)
{
yield return SendDevice("DELETE", fid, "android", onComplete);
}
static IEnumerator SendDevice(string method, string fid, string platform, Action<SupportCallResult<SupportDeviceResponse>> onComplete)
{
fid = fid != null ? fid.Trim() : "";
if (string.IsNullOrEmpty(fid))
{
var invalid = Fail<SupportDeviceResponse>(400, "Invalid installation id");
LogFailure("/api/support/player/devices", invalid);
onComplete?.Invoke(invalid);
yield break;
}
string json = JsonUtility.ToJson(new DeviceRequest
{
fid = fid,
platform = string.IsNullOrEmpty(platform) ? "android" : platform.Trim()
});
HttpOutcome http = null;
yield return Request(method, "/api/support/player/devices", json, true, r => http = r);
if (http != null && http.statusCode == 401 && IsSignedIn)
ClearSession();
SupportCallResult<SupportDeviceResponse> result = ParseAck<SupportDeviceResponse>(http);
LogFailure("/api/support/player/devices", result);
onComplete?.Invoke(result);
}
/// <summary>GET /api/support/player/tickets. Open tickets first, then newest message.</summary>
public static IEnumerator ListTickets(Action<SupportCallResult<SupportTicketListResponse>> onComplete)
{
yield return SendAuthorized("/api/support/player/tickets", "GET", null, onComplete, NormalizeTickets);
}
/// <summary>POST /api/support/player/tickets. Each call creates a new open ticket.</summary>
public static IEnumerator CreateTicket(string category, string body, Action<SupportCallResult<SupportThread>> onComplete)
/// <summary>
/// POST /api/support/player/tickets. Each call creates a new open ticket.
/// <paramref name="logs"/> is the Unity player log, sent on create only. Null or whitespace omits it.
/// </summary>
public static IEnumerator CreateTicket(string category, string body, string logs, Action<SupportCallResult<SupportThread>> onComplete)
{
category = category != null ? category.Trim() : "";
if (!IsCategory(category))
@@ -184,10 +226,71 @@ public static class SupportClient
yield break;
}
string json = JsonUtility.ToJson(new CreateTicketRequest { category = category, body = body });
logs = string.IsNullOrWhiteSpace(logs) ? null : logs.Trim();
if (logs != null && logs.Length > MaxLogsLength)
{
var invalid = Fail<SupportThread>(400, LogsLengthError);
LogFailure("/api/support/player/tickets", invalid);
onComplete?.Invoke(invalid);
yield break;
}
string json = JsonUtility.ToJson(new CreateTicketRequest { category = category, body = body, logs = logs });
yield return SendAuthorized("/api/support/player/tickets", "POST", json, onComplete, NormalizeThread);
}
/// <summary>
/// Reads <see cref="Application.consoleLogPath"/> for a new ticket.
/// Returns null when the log is missing or empty. Keeps the newest text when the file is longer than <see cref="MaxLogsLength"/>.
/// </summary>
public static string ReadUnityPlayerLog()
{
string path = Application.consoleLogPath;
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
bool truncated = false;
long maxBytes = (long)MaxLogsLength * 4;
if (stream.Length > maxBytes)
{
stream.Seek(-maxBytes, SeekOrigin.End);
truncated = true;
}
using (var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, true))
{
string text;
if (truncated)
{
string partial = reader.ReadLine();
string rest = reader.ReadToEnd();
text = string.IsNullOrEmpty(rest) ? partial : rest;
}
else
{
text = reader.ReadToEnd();
}
if (string.IsNullOrWhiteSpace(text))
return null;
text = text.Trim();
if (text.Length > MaxLogsLength)
text = text.Substring(text.Length - MaxLogsLength);
return text;
}
}
}
catch (Exception e)
{
Debug.LogWarning("[SupportClient] could not read Unity log: " + e.Message);
return null;
}
}
/// <summary>GET /api/support/player/tickets/{ticketId}. Messages are oldest first.</summary>
public static IEnumerator GetThread(string ticketId, Action<SupportCallResult<SupportThread>> onComplete)
{
@@ -279,17 +382,16 @@ public static class SupportClient
}
string url = RootUrl() + path;
Debug.Log("[SupportClient] " + method + " " + url);
using (var request = new UnityWebRequest(url, method))
{
request.downloadHandler = new DownloadHandlerBuffer();
request.timeout = RequestTimeoutSeconds;
if (method == UnityWebRequest.kHttpVerbPOST)
{
byte[] raw = Encoding.UTF8.GetBytes(json ?? "{}");
request.uploadHandler = new UploadHandlerRaw(raw);
request.SetRequestHeader("Content-Type", "application/json");
}
request.timeout = json != null && json.Length > 64 * 1024 ? 120 : RequestTimeoutSeconds;
if (json != null || method == UnityWebRequest.kHttpVerbPOST)
{
byte[] raw = Encoding.UTF8.GetBytes(json ?? "{}");
request.uploadHandler = new UploadHandlerRaw(raw);
request.SetRequestHeader("Content-Type", "application/json");
}
if (authorize)
request.SetRequestHeader("Authorization", "Bearer " + Token);
@@ -347,6 +449,23 @@ public static class SupportClient
};
}
/// <summary>2xx with an empty body still counts as success.</summary>
static SupportCallResult<T> ParseAck<T>(HttpOutcome http) where T : class, new()
{
SupportCallResult<T> parsed = ParseOk<T>(http);
if (parsed.ok || http == null || http.statusCode < 200 || http.statusCode >= 300)
return parsed;
if (parsed.error != "Invalid response from server")
return parsed;
return new SupportCallResult<T>
{
ok = true,
statusCode = http.statusCode,
data = new T()
};
}
static SupportCallResult<T> Fail<T>(long statusCode, string error, int retryAfterSeconds = 0)
{
return new SupportCallResult<T>
@@ -467,6 +586,7 @@ public static class SupportClient
{
public string category;
public string body;
public string logs;
}
[Serializable]
@@ -475,6 +595,13 @@ public static class SupportClient
public string body;
}
[Serializable]
class DeviceRequest
{
public string fid;
public string platform;
}
[Serializable]
public class SupportErrorBody
{
@@ -519,6 +646,7 @@ public class SupportTicket
public string updatedAt;
public string lastMessageAt;
public string lastMessagePreview;
public bool hasLogs;
}
[Serializable]
@@ -544,3 +672,8 @@ public class SupportTicketListResponse
{
public SupportTicket[] tickets;
}
[Serializable]
public class SupportDeviceResponse
{
}