diff --git a/source/plugin/Assets/GoogleMobileAds/Common/AssemblyInfo.cs b/source/plugin/Assets/GoogleMobileAds/Common/AssemblyInfo.cs index 231afed9d..52c39619d 100644 --- a/source/plugin/Assets/GoogleMobileAds/Common/AssemblyInfo.cs +++ b/source/plugin/Assets/GoogleMobileAds/Common/AssemblyInfo.cs @@ -22,3 +22,4 @@ [assembly: InternalsVisibleTo("GoogleMobileAds.iOS")] [assembly: InternalsVisibleTo("GoogleMobileAds.Unity")] [assembly: InternalsVisibleTo("GoogleMobileAdsNative.Api")] +[assembly: InternalsVisibleTo("UnitTests")] diff --git a/source/plugin/Assets/GoogleMobileAds/Common/GlobalExceptionHandler.cs b/source/plugin/Assets/GoogleMobileAds/Common/GlobalExceptionHandler.cs new file mode 100644 index 000000000..311377857 --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Common/GlobalExceptionHandler.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +namespace GoogleMobileAds.Common +{ + #region Exception payload definition + [Serializable] + public struct ExceptionLoggablePayload + { + public ExceptionReport unity_gma_sdk_exception_message; + } + + /// + /// A data structure to hold all relevant info for a single exception event. + /// + [Serializable] + public class ExceptionReport + { + // JSPB compatibility: 64-bit integers must be sent as strings to avoid precision loss. + public string time_msec; + public bool trapped; + public string name; + public string exception_class; + public string top_exception; + public string stacktrace; + public string stacktrace_hash; + + // Static metadata. + public string session_id; + public string app_id; + public string app_version_name; + public string platform; + public string unity_version; + public string os_version; + public string device_model; + public string country; + public int total_cpu; + public string total_memory_bytes; + + // Dynamic metadata. + public string network_type; + public string orientation; + } + #endregion + + /// + /// A persistent singleton that captures all trapped and untrapped C# exceptions. + /// It enriches them with device metadata and sends them in batches to a backend service (RCS) + /// based on either a count or time threshold. + /// + public class GlobalExceptionHandler : RcsClient + { + private static GlobalExceptionHandler _instance; + public static GlobalExceptionHandler Instance + { + get + { + if (_instance == null && Application.isPlaying) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + _instance = new GameObject("GlobalExceptionHandler") + .AddComponent(); + } + } + return _instance; + } + private set + { + _instance = value; + } + } + + #region Unity lifecycle methods + private void Awake() + { + // Enforce the singleton pattern. + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + DontDestroyOnLoad(gameObject); + } + + private void OnEnable() + { + Application.logMessageReceivedThreaded += OnLogMessageReceivedThreaded; + } + + private void OnDisable() + { + Application.logMessageReceivedThreaded -= OnLogMessageReceivedThreaded; + } + #endregion + + #region Public reporting method + /// + /// Call this from any 'try-catch' block to report a TRAPPED exception. + /// This method is thread-safe and adds the exception to the queue. + /// + public void ReportTrappedException(Exception e, string name = null) + { + if (e == null) return; + + var report = new ExceptionReport + { + time_msec = GetEpochMillis(), + trapped = true, + name = name, + exception_class = e.GetType().FullName, + top_exception = e.Message, + stacktrace = e.StackTrace ?? "", + stacktrace_hash = Sha256Hash(e.StackTrace ?? ""), + }; + Enqueue(report); + if (Debug.isDebugBuild) + { + Debug.Log("Trapped exception queued for batch."); + } + } + #endregion + + #region Core logic + /// + /// This callback handles UNTRAPPED exceptions from *any* thread. + /// It must be thread-safe and very fast. + /// + internal void OnLogMessageReceivedThreaded(string logString, string stackTrace, LogType type) + { + if (type != LogType.Exception) return; + + // Parse exception details from the log string. + string topException = logString.Split(new[] { '\n' }, 2)[0].Trim(); + string exceptionClass = topException.Split(':')[0].Trim(); + + var report = new ExceptionReport + { + time_msec = GetEpochMillis(), + trapped = false, + exception_class = exceptionClass, + top_exception = topException, + stacktrace = stackTrace ?? "", + stacktrace_hash = Sha256Hash(stackTrace ?? ""), + }; + Enqueue(report); + if (Debug.isDebugBuild) + { + Debug.Log("Untrapped exception queued for batch."); + } + } + + private string Sha256Hash(string rawData) + { + try + { + using (SHA256 sha256Hash = SHA256.Create()) + { + byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData)); + // Convert each byte of the computed hash into a two-character hexadecimal + // string. + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < bytes.Length; i++) + { + builder.Append(bytes[i].ToString("X2")); + } + return builder.ToString(); + } + } + catch (Exception) + { + return ""; + } + } + + /// + /// Builds and sends a batch of exception reports. + /// + protected override void SendBatch(List batch) + { + if (Debug.isDebugBuild) + { + Debug.Log(string.Format("Processing and sending a batch of {0} exceptions...", + batch.Count)); + } + + var staticMetadata = RcsPayload.GetStaticMetadata(); + var dynamicMetadata = RcsPayload.GetDynamicMetadata(); + + foreach(var report in batch) + { + report.session_id = staticMetadata.session_id; + report.app_id = staticMetadata.app_id; + report.app_version_name = staticMetadata.app_version_name; + report.platform = staticMetadata.platform; + report.unity_version = staticMetadata.unity_version; + report.os_version = staticMetadata.os_version; + report.device_model = staticMetadata.device_model; + report.country = staticMetadata.country; + report.total_cpu = staticMetadata.total_cpu; + report.total_memory_bytes = staticMetadata.total_memory_bytes; + report.network_type = dynamicMetadata.network_type; + report.orientation = dynamicMetadata.orientation; + } + + var payloads = new List(); + foreach (var report in batch) + { + payloads.Add(new ExceptionLoggablePayload + { + unity_gma_sdk_exception_message = report + }); + } + + var request = new LoggableRemoteCaptureRequest + { + payloads = payloads, + client_ping_metadata = new ClientPingMetadata + { + binary_name = 21, // UNITY_GMA_SDK + } + }; + string jspbPayload = JspbConverter.ToJspb(request); + if (jspbPayload != null) + { + SendToRcs(jspbPayload); + } + } + #endregion + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Common/Insight.cs b/source/plugin/Assets/GoogleMobileAds/Common/Insight.cs index 2e5e74cb6..b036a7860 100644 --- a/source/plugin/Assets/GoogleMobileAds/Common/Insight.cs +++ b/source/plugin/Assets/GoogleMobileAds/Common/Insight.cs @@ -40,6 +40,7 @@ private static void CacheBaseProperties() public Insight() { + Success = true; StartTimeEpochMillis = (long)DateTime.UtcNow .Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)) .TotalMilliseconds; diff --git a/source/plugin/Assets/GoogleMobileAds/Common/JspbConverter.cs b/source/plugin/Assets/GoogleMobileAds/Common/JspbConverter.cs new file mode 100644 index 000000000..27aa04523 --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Common/JspbConverter.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace GoogleMobileAds.Common +{ + /// + /// A helper class to serialize objects to JSPB format. + /// + internal static class JspbConverter + { + internal static string ToJspb(LoggableRemoteCaptureRequest request) + { + var payloads = new List(); + if (request.payloads != null) + { + foreach (var payload in request.payloads) + { + if (payload is ExceptionLoggablePayload) + { + payloads.Add(ToJspb((ExceptionLoggablePayload)(object)payload)); + } + else + { + Debug.LogError("JspbConverter encountered an unknown payload type: " + + payload.GetType()); + } + } + } + if (payloads.Count == 0) + { + Debug.LogError("No payloads found in the request."); + return null; + } + return string.Format("[[{0}],{1}]", + string.Join(",", payloads.ToArray()), + ToJspb(request.client_ping_metadata)); + } + + // VisibleForTesting + internal static string ToJspb(ClientPingMetadata metadata) + { + return string.Format("[{0}]", metadata.binary_name); + } + + // VisibleForTesting + internal static string QuoteString(string s) + { + if (s == null) return "null"; + + StringBuilder sb = new StringBuilder(); + sb.Append("\""); + foreach (char c in s) + { + switch (c) + { + // Escape quotes and slashes. + case '\"': + sb.Append("\\\""); + break; + case '\\': + sb.Append("\\\\"); + break; + case '/': + sb.Append("\\/"); + break; + // Escape control characters. + case '\b': + sb.Append("\\b"); + break; + case '\f': + sb.Append("\\f"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + // Characters within the printable ASCII range (32-126) are appended + // directly. Other characters (control characters or outside ASCII) are + // escaped as Unicode. + int i = (int)c; + if (i < 32 || i > 126) + { + sb.AppendFormat("\\u{0:X4}", i); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append("\""); + return sb.ToString(); + } + + #region Exceptions handling + // VisibleForTesting + internal static string ToJspb(ExceptionLoggablePayload payload) + { + if (payload.unity_gma_sdk_exception_message == null) return "[]"; + + // unity_gma_sdk_exception_message has field index 35. + return string.Format("[{{\"35\":{0}}}]", + ToJspb(payload.unity_gma_sdk_exception_message)); + } + + // VisibleForTesting + internal static string ToJspb(ExceptionReport report) + { + return string.Format( + "[{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15},{16},{17},{18}]", + QuoteString(report.time_msec), + report.trapped.ToString().ToLower(), + QuoteString(report.name), + QuoteString(report.exception_class), + QuoteString(report.top_exception), + QuoteString(report.stacktrace), + QuoteString(report.stacktrace_hash), + QuoteString(report.session_id), + QuoteString(report.app_id), + QuoteString(report.app_version_name), + QuoteString(report.platform), + QuoteString(report.unity_version), + QuoteString(report.os_version), + QuoteString(report.device_model), + QuoteString(report.country), + report.total_cpu, + QuoteString(report.total_memory_bytes), + QuoteString(report.network_type), + QuoteString(report.orientation)); + } + #endregion + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Common/RcsClient.cs b/source/plugin/Assets/GoogleMobileAds/Common/RcsClient.cs new file mode 100644 index 000000000..fdf56a601 --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Common/RcsClient.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +namespace GoogleMobileAds.Common +{ + [Serializable] + public struct LoggableRemoteCaptureRequest + { + public List payloads; + public ClientPingMetadata client_ping_metadata; + } + + [Serializable] + public struct ClientPingMetadata + { + public int binary_name; + } + + /// + /// An abstract base class for clients that send batches of items to RCS. + /// It handles queueing, batching triggers (count or time), and POSTing data. + /// + public abstract class RcsClient : MonoBehaviour where TReport : class + { + // Batching triggers can be overridden by subclasses. We don't need to expose them in Unity + // Editor. If any trigger fires, a batch of items will get sent. + protected virtual int CountThreshold => 20; + protected virtual float TimeThresholdInSeconds => 120.0f; + + // RCS endpoint for reporting. The `e=1` URL parameter defines JSPB encoding. + private const string ProdRcsUrl = "https://pagead2.googlesyndication.com/pagead/ping?e=1"; + + internal static readonly Queue _queue = new Queue(); + private static readonly object _queueLock = new object(); + private float _timeOfNextBatch; + + /// + /// Initializes the client when it is enabled. + /// + private void Start() + { + RcsPayload.InitializeStaticMetadata(); + ResetBatchTimer(); + } + + /// + /// Runs every frame to check if either of our batching triggers has been met. + /// + private void Update() + { + int count; + lock (_queueLock) + { + count = _queue.Count; + } + bool isCountThresholdMet = count >= CountThreshold; + bool isTimeThresholdMet = Time.time >= _timeOfNextBatch; + if (isCountThresholdMet || isTimeThresholdMet) + { + ProcessAndSendBatch(); + } + } + + /// + /// Sends pending items before the application quits. + /// + private void OnApplicationQuit() + { + ProcessAndSendBatch(); + } + + /// + /// Adds an item to the queue. This method is thread-safe. + /// + protected void Enqueue(TReport item) + { + if (item == null) return; + lock (_queueLock) + { + _queue.Enqueue(item); + } + } + + /// + /// Returns the Unix epoch in milliseconds. + /// + protected string GetEpochMillis() + { + return ((long)DateTime.UtcNow + .Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)) + .TotalMilliseconds).ToString(); + } + + /// + /// Sends the batch of items to RCS. + /// + protected void SendToRcs(string jspbPayload) + { + if (Debug.isDebugBuild) + { + Debug.Log("RCS JSPB payload: " + jspbPayload); + } + StartCoroutine(PostRequest(ProdRcsUrl, jspbPayload)); + } + + /// + /// Drains the queue and passes the resulting batch to be sent. + /// + internal void ProcessAndSendBatch() + { + ResetBatchTimer(); + List batch = new List(); + lock (_queueLock) + { + if (_queue.Count == 0) return; + while(_queue.Count > 0) + { + batch.Add(_queue.Dequeue()); + } + } + + if (batch.Count > 0) + { + SendBatch(batch); + } + } + + /// + /// Resets the batch timer to the current time plus the threshold. + /// + private void ResetBatchTimer() + { + _timeOfNextBatch = Time.time + TimeThresholdInSeconds; + } + + /// + /// Coroutine to send a JSPB payload via HTTP POST. + /// + private IEnumerator PostRequest(string url, string jspbPayload) + { + using (UnityWebRequest uwr = new UnityWebRequest(url, "POST")) + { + byte[] bodyRaw = Encoding.UTF8.GetBytes(jspbPayload); + uwr.uploadHandler = new UploadHandlerRaw(bodyRaw); + uwr.downloadHandler = new DownloadHandlerBuffer(); + uwr.SetRequestHeader("Content-Type", "application/json"); + + yield return uwr.SendWebRequest(); + +#if UNITY_2020_2_OR_NEWER + if (uwr.result != UnityWebRequest.Result.Success) +#else + if (uwr.isHttpError || uwr.isNetworkError) +#endif + { + Debug.LogError(string.Format( + "Error sending batch: {0} | Response code: {1}.", + uwr.error, uwr.responseCode)); + } + else if (Debug.isDebugBuild) + { + // This only guarantees transport, not that the request is fully processed as + // RCS will just drop unknown fields if it can't otherwise parse them. + Debug.Log("Batch sent successfully."); + } + } + } + + /// + /// Concrete classes must implement this to process and send a batch of items. + /// + protected abstract void SendBatch(List batch); + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Common/RcsPayload.cs b/source/plugin/Assets/GoogleMobileAds/Common/RcsPayload.cs new file mode 100644 index 000000000..c3bf929f6 --- /dev/null +++ b/source/plugin/Assets/GoogleMobileAds/Common/RcsPayload.cs @@ -0,0 +1,90 @@ +using System; +using System.Globalization; +using UnityEngine; + +namespace GoogleMobileAds.Common +{ + /// + /// Holds all static metadata about the SDK environment. This info is gathered once at startup. + /// + [Serializable] + public struct StaticMetadata + { + public string session_id; + public string app_id; + public string app_version_name; + public string platform; + public string unity_version; + public string os_version; + public string device_model; + public string country; + public int total_cpu; + // JSPB compatibility: 64-bit integers must be sent as strings to avoid precision loss. + public string total_memory_bytes; + } + + /// + /// Holds all dynamic metadata about the SDK environment. This info is gathered at request time. + /// + [Serializable] + public struct DynamicMetadata + { + public string network_type; + public string orientation; + } + + /// + /// Helper class for RCS message payloads to retrieve static and dynamic metadata. + /// + public static class RcsPayload + { + private static StaticMetadata _staticMetadata; + private static bool _staticMetadataInitialized = false; + + /// + /// Gathers all static device and app info once. Must be called from main thread during + /// initialization. + /// + public static void InitializeStaticMetadata() + { + if (_staticMetadataInitialized) + { + return; + } + _staticMetadata = new StaticMetadata + { + session_id = System.Guid.NewGuid().ToString(), + app_id = Application.identifier, + app_version_name = Application.version, + platform = Application.platform.ToString(), + unity_version = Application.unityVersion, + os_version = SystemInfo.operatingSystem, + device_model = SystemInfo.deviceModel, + country = RegionInfo.CurrentRegion.TwoLetterISORegionName, + total_cpu = SystemInfo.processorCount, + // Convert MB to bytes. + total_memory_bytes = ((long)SystemInfo.systemMemorySize * 1024 * 1024).ToString(), + }; + _staticMetadataInitialized = true; + } + + public static StaticMetadata GetStaticMetadata() + { + if (!_staticMetadataInitialized) + { + throw new InvalidOperationException("Static metadata not initialized. " + + "Call 'InitializeStaticMetadata()' first from the main thread."); + } + return _staticMetadata; + } + + public static DynamicMetadata GetDynamicMetadata() + { + return new DynamicMetadata + { + network_type = Application.internetReachability.ToString(), + orientation = Screen.orientation.ToString(), + }; + } + } +} diff --git a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/MobileAdsClient.cs b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/MobileAdsClient.cs index 192ecbc49..cb5742b7d 100644 --- a/source/plugin/Assets/GoogleMobileAds/Platforms/Android/MobileAdsClient.cs +++ b/source/plugin/Assets/GoogleMobileAds/Platforms/Android/MobileAdsClient.cs @@ -35,6 +35,8 @@ public class MobileAdsClient : AndroidJavaProxy, IMobileAdsClient private MobileAdsClient() : base(Utils.OnInitializationCompleteListenerClassName) { _mobileAdsClass = new AndroidJavaClass(Utils.UnityMobileAdsClassName); _tracer = new Tracer(_insightsEmitter); + // Ensures GlobalExceptionHandler is initialized to handle Android untrapped exceptions. + var _ = GlobalExceptionHandler.Instance; } public static MobileAdsClient Instance @@ -69,8 +71,7 @@ public void Initialize(Action initCompleteAction) } _insightsEmitter.Emit(new Insight() { - Name = Insight.CuiName.SdkInitialized, - Success = true + Name = Insight.CuiName.SdkInitialized }); } @@ -177,14 +178,17 @@ public void onInitializationComplete(AndroidJavaObject initStatus) _initCompleteAction(statusClient); } string nativePluginVersion = ""; - try - { + try { var assembly = Assembly.Load("GoogleMobileAdsNative.Common"); var assemblyVersion = assembly.GetName().Version; nativePluginVersion = string.Format("{0}.{1}.{2}", assemblyVersion.Major, assemblyVersion.Minor, assemblyVersion.Revision); + } catch (Exception e) { + if (GlobalExceptionHandler.Instance != null) + { + GlobalExceptionHandler.Instance.ReportTrappedException(e); + } } - catch (Exception) {} string versionString = AdRequest.BuildVersionString(nativePluginVersion); _mobileAdsClass.CallStatic("setPlugin", versionString); }