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);
}