push notifications
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user