using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; using UnityEngine.Networking; namespace BugReporter { /// /// Owns the upload: screenshot capture, multipart POST, retry with backoff, and a disk queue so a report /// filed offline (or during a crash-adjacent moment) survives to the next launch. /// internal sealed class ReportSender : MonoBehaviour { private const int MaxRetries = 3; private static string QueueDir => Path.Combine(Application.persistentDataPath, "bugreporter-queue"); public static ReportSender Create() { var go = new GameObject("[BugReporter]"); DontDestroyOnLoad(go); go.hideFlags = HideFlags.HideInHierarchy; var sender = go.AddComponent(); sender.StartCoroutine(sender.FlushQueue()); return sender; } public void Submit(ReportPayload payload) => StartCoroutine(CaptureAndSend(payload)); private IEnumerator CaptureAndSend(ReportPayload payload) { // The screenshot must be taken after the frame has finished rendering, or we capture a half-drawn // frame (or throw on some platforms). yield return new WaitForEndOfFrame(); byte[] screenshot = null, thumbnail = null; try { var tex = ScreenCapture.CaptureScreenshotAsTexture(); try { screenshot = tex.EncodeToJPG(Mathf.Clamp(BugReporter.Config.ScreenshotQuality, 1, 100)); // A small thumbnail travels with the report so the dashboard grid can show previews without // downloading every full screenshot (which was making the grid crawl). Full shot stays for detail. try { thumbnail = MakeThumbnailJpg(tex, 400, 55); } catch (Exception te) { Debug.LogWarning($"[BugReporter] Thumbnail failed ({te.Message}) — sending full shot only."); } } finally { Destroy(tex); } } catch (Exception e) { // A missing screenshot must never lose the report — the logs are the valuable half. Debug.LogWarning($"[BugReporter] Screenshot capture failed ({e.Message}) — sending report without it."); } yield return Send(payload.ToJson(), payload.logs, screenshot, thumbnail, payload.clip, allowQueue: true); } /// Downscale a screenshot to a small JPEG (longest side ≤ maxDim) via a GPU blit — cheap, and the /// grid loads these instead of full frames. private static byte[] MakeThumbnailJpg(Texture2D src, int maxDim, int quality) { float scale = Mathf.Min(1f, (float)maxDim / Mathf.Max(src.width, src.height)); int tw = Mathf.Max(1, Mathf.RoundToInt(src.width * scale)); int th = Mathf.Max(1, Mathf.RoundToInt(src.height * scale)); var rt = RenderTexture.GetTemporary(tw, th, 0, RenderTextureFormat.ARGB32); var prev = RenderTexture.active; Texture2D small = null; try { Graphics.Blit(src, rt); RenderTexture.active = rt; small = new Texture2D(tw, th, TextureFormat.RGB24, false); small.ReadPixels(new Rect(0, 0, tw, th), 0, 0); small.Apply(); return small.EncodeToJPG(quality); } finally { RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt); if (small != null) Destroy(small); } } private IEnumerator Send(string json, string logs, byte[] screenshot, byte[] thumbnail, byte[] clip, bool allowQueue) { for (int attempt = 1; attempt <= MaxRetries; attempt++) { var form = new List { new MultipartFormDataSection("report", json, Encoding.UTF8, "application/json"), }; if (!string.IsNullOrEmpty(logs)) form.Add(new MultipartFormFileSection("logs", Encoding.UTF8.GetBytes(logs), "logs.txt", "text/plain")); if (screenshot != null && screenshot.Length > 0) form.Add(new MultipartFormFileSection("screenshot", screenshot, "screenshot.jpg", "image/jpeg")); if (thumbnail != null && thumbnail.Length > 0) form.Add(new MultipartFormFileSection("thumbnail", thumbnail, "thumb.jpg", "image/jpeg")); if (clip != null && clip.Length > 0) form.Add(new MultipartFormFileSection("clip", clip, "clip.bin", "application/octet-stream")); Debug.Log($"[BugReporter] Sending report — screenshot {(screenshot?.Length ?? 0) / 1024}KB, " + $"clip {(clip?.Length ?? 0) / 1024}KB, logs {(logs?.Length ?? 0) / 1024}KB (attempt {attempt}/{MaxRetries})."); using (var req = UnityWebRequest.Post(BugReporter.Config.Endpoint, form)) { req.SetRequestHeader("X-Api-Key", BugReporter.Config.ApiKey); req.timeout = clip != null ? 120 : 30; // a multi-MB clip won't clear 30s on mobile data yield return req.SendWebRequest(); if (req.result == UnityWebRequest.Result.Success) { Debug.Log("[BugReporter] Report sent."); yield break; } // 413 = the body blew the server's request cap, which in practice means the clip. The clip // is the least valuable part — drop it and resend so the logs and screenshot still land, // instead of losing the whole report. if (req.responseCode == 413 && clip != null) { Debug.LogWarning($"[BugReporter] Report too large ({clip.Length / 1048576f:F1}MB clip) — resending without the clip."); clip = null; continue; } // Any other 4xx means the server rejected the report itself (bad key, malformed body). // Retrying or queueing it would just repeat the rejection — drop it and say why. bool clientError = req.responseCode >= 400 && req.responseCode < 500; if (clientError) { Debug.LogError($"[BugReporter] Report rejected ({req.responseCode}): {req.downloadHandler?.text}"); yield break; } Debug.LogWarning($"[BugReporter] Send failed (attempt {attempt}/{MaxRetries}): {req.error}"); } if (attempt < MaxRetries) yield return new WaitForSecondsRealtime(Mathf.Pow(2f, attempt)); // 2s, 4s } if (allowQueue && BugReporter.Config.QueueFailedReports) QueueToDisk(json, logs, screenshot, thumbnail, clip); } // ── Offline queue ──────────────────────────────────────────────────────────────────────────── // One folder per report so a partial write can be detected (report.json is written last, and is what // FlushQueue keys off). private void QueueToDisk(string json, string logs, byte[] screenshot, byte[] thumbnail, byte[] clip) { try { string dir = Path.Combine(QueueDir, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); if (!string.IsNullOrEmpty(logs)) File.WriteAllText(Path.Combine(dir, "logs.txt"), logs); if (screenshot != null) File.WriteAllBytes(Path.Combine(dir, "screenshot.jpg"), screenshot); if (thumbnail != null) File.WriteAllBytes(Path.Combine(dir, "thumb.jpg"), thumbnail); if (clip != null) File.WriteAllBytes(Path.Combine(dir, "clip.bin"), clip); File.WriteAllText(Path.Combine(dir, "report.json"), json); // last — the completion marker Debug.Log("[BugReporter] Offline — report queued, will retry on next launch."); } catch (Exception e) { Debug.LogError($"[BugReporter] Could not queue report: {e.Message}"); } } private IEnumerator FlushQueue() { if (!Directory.Exists(QueueDir)) yield break; foreach (string dir in Directory.GetDirectories(QueueDir)) { string jsonPath = Path.Combine(dir, "report.json"); if (!File.Exists(jsonPath)) { TryDelete(dir); continue; } // partial write — nothing to send string json = File.ReadAllText(jsonPath); string logsPath = Path.Combine(dir, "logs.txt"); string shotPath = Path.Combine(dir, "screenshot.jpg"); string thumbPath = Path.Combine(dir, "thumb.jpg"); string clipPath = Path.Combine(dir, "clip.bin"); string logs = File.Exists(logsPath) ? File.ReadAllText(logsPath) : null; byte[] shot = File.Exists(shotPath) ? File.ReadAllBytes(shotPath) : null; byte[] thumb = File.Exists(thumbPath) ? File.ReadAllBytes(thumbPath) : null; byte[] clip = File.Exists(clipPath) ? File.ReadAllBytes(clipPath) : null; // allowQueue:false — a queued report that fails again must not re-queue itself forever. yield return Send(json, logs, shot, thumb, clip, allowQueue: false); TryDelete(dir); } } private static void TryDelete(string dir) { try { Directory.Delete(dir, recursive: true); } catch { /* next launch will retry the delete */ } } } }