From 9522cc19c5629317799410ec59ad40c1f1bbedec Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 20:58:12 +0500 Subject: [PATCH 1/9] Find or download bugsee-cli for the Editor symbol uploads instead of relying on the Editor's PATH. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Unity Editor started from the Hub or the Finder does not inherit the shell PATH, so a bare bugsee-cli failed with "Cannot find the specified file" and no symbol was uploaded. Look at BUGSEE_CLI_PATH, PATH, the installers' directories and a cache, then download the pinned release (0.8.1) from download.bugsee.com with a SHA-256 check when a token is set. Never fails the build: one warning when nothing is found. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeCliLocator.cs | 377 ++++++++++++++++ Editor/BugseeCliLocator.cs.meta | 11 + Editor/BugseeCliRunner.cs | 19 +- Runtime/WrapperPolicy/BugseeCliPolicy.cs | 403 ++++++++++++++++++ Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta | 11 + Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 293 +++++++++++++ 6 files changed, 1112 insertions(+), 2 deletions(-) create mode 100644 Editor/BugseeCliLocator.cs create mode 100644 Editor/BugseeCliLocator.cs.meta create mode 100644 Runtime/WrapperPolicy/BugseeCliPolicy.cs create mode 100644 Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/BugseeCliPolicyTests.cs diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs new file mode 100644 index 0000000..61f10a2 --- /dev/null +++ b/Editor/BugseeCliLocator.cs @@ -0,0 +1,377 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Bugsee.WrapperPolicy; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Bugsee.Editor +{ + /// + /// Resolves the bugsee-cli binary for the Editor symbol uploads (see for the + /// order and why): BUGSEE_CLI_PATH, PATH, the well-known install directories, a cache, then a SHA-256-verified + /// download of the pinned release from download.bugsee.com (opt out: BUGSEE_NO_CLI_DOWNLOAD; pin another release: + /// BUGSEE_CLI_VERSION; mirror: BUGSEE_CLI_BASE_URL, the variables install.sh reads). Never throws; the + /// result is remembered until the next build starts. + /// + internal static class BugseeCliLocator + { + static CliResolution _resolved; + static bool _notFoundReported; + + public static CliResolution Resolve() + { + if (_resolved != null && (!_resolved.Found || File.Exists(_resolved.Path))) + { + return _resolved; + } + + try + { + CliSettings settings = ReadSettings(); + _resolved = BugseeCliPolicy.Resolve(settings, new CliProbe + { + FileExists = File.Exists, + QueryVersion = QueryVersion, + Download = (cacheDir, version) => Download(settings, cacheDir, version), + }); + } + catch (Exception ex) + { + _resolved = new CliResolution(); + _resolved.Notes.Add("Looking for bugsee-cli failed: " + ex.Message); + } + + if (_resolved.Found) + { + Debug.Log("[Bugsee] Using bugsee-cli " + (_resolved.Version ?? "(version unknown)") + " (" + Describe(_resolved.Source) + "): " + _resolved.Path); + foreach (string note in _resolved.Notes) + { + Debug.LogWarning("[Bugsee] " + note); + } + } + + return _resolved; + } + + /// Warns once per build that no CLI was found; false when one was. + public static bool ReportIfMissing() + { + CliResolution resolution = Resolve(); + if (resolution.Found) + { + return false; + } + + if (!_notFoundReported) + { + _notFoundReported = true; + Debug.LogWarning(BugseeCliPolicy.NotFoundMessage(resolution)); + } + + return true; + } + + internal static void Reset() + { + _resolved = null; + _notFoundReported = false; + } + + static string Describe(CliSource source) + { + switch (source) + { + case CliSource.Explicit: return "BUGSEE_CLI_PATH"; + case CliSource.Path: return "PATH"; + case CliSource.KnownDirectory: return "install directory"; + case CliSource.Cache: return "cached download"; + case CliSource.Downloaded: return "downloaded"; + default: return "unresolved"; + } + } + + static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + static string HostOs => + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macos" + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : IsWindows ? "windows" : ""; + + static CliSettings ReadSettings() + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string localAppData = IsWindows ? Environment.GetEnvironmentVariable("LOCALAPPDATA") : null; + string version = Environment.GetEnvironmentVariable("BUGSEE_CLI_VERSION"); + var settings = new CliSettings + { + ExplicitPath = Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH"), + PathEnv = Environment.GetEnvironmentVariable("PATH"), + Home = home, + LocalAppData = localAppData, + IsWindows = IsWindows, + Version = string.IsNullOrWhiteSpace(version) ? BugseeCliPolicy.DefaultVersion : version.Trim().TrimStart('v'), + Triple = BugseeCliPolicy.HostTriple(HostOs, RuntimeInformation.OSArchitecture.ToString()), + DownloadAllowed = BugseeCliPolicy.DownloadAllowed( + BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), + }; + + // Shared by every project of this user first; the project's own Library when that is not writable. + string userCache = null; + if (IsWindows && !string.IsNullOrEmpty(localAppData)) + { + userCache = Path.Combine(localAppData, "Bugsee", "cache", "cli"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(home)) + { + userCache = Path.Combine(home, "Library", "Caches", "Bugsee", "cli"); + } + else if (!string.IsNullOrEmpty(home)) + { + string xdg = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + userCache = Path.Combine(string.IsNullOrEmpty(xdg) ? Path.Combine(home, ".cache") : xdg, "bugsee", "cli"); + } + + if (userCache != null) + { + settings.CacheDirectories.Add(userCache); + } + + settings.CacheDirectories.Add(Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)), "Library", "Bugsee", "cli")); + return settings; + } + + static string QueryVersion(string path) + { + string output; + int exit = Capture(path, "--version", 15000, out output); + return exit == 0 ? BugseeCliPolicy.ParseVersionOutput(output) : null; + } + + /// Runs a process, returns its exit code (-1 when it did not start or run to the end) and stdout. + static int Capture(string file, string args, int timeoutMs, out string stdout) + { + stdout = null; + try + { + var psi = new ProcessStartInfo + { + FileName = file, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using (Process proc = Process.Start(psi)) + { + if (proc == null) + { + return -1; + } + + // Both pipes are drained off-thread: a child that fills one would stall, and a hung one must not + // block the build past the timeout. + string text = null; + var outThread = new Thread(() => { try { text = proc.StandardOutput.ReadToEnd(); } catch { /* ignore */ } }); + var errThread = new Thread(() => { try { proc.StandardError.ReadToEnd(); } catch { /* ignore */ } }); + outThread.IsBackground = true; + errThread.IsBackground = true; + outThread.Start(); + errThread.Start(); + if (!proc.WaitForExit(timeoutMs)) + { + try { proc.Kill(); } catch { /* ignore */ } + return -1; + } + + outThread.Join(5000); + errThread.Join(5000); + stdout = text; + return proc.ExitCode; + } + } + catch (Exception) + { + return -1; + } + } + + static string Download(CliSettings settings, string cacheDir, string version) + { + string triple = settings.Triple; + string binaryName = BugseeCliPolicy.BinaryName(BugseeCliPolicy.IsWindowsTriple(triple)); + string finalDir = Path.GetDirectoryName(BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, triple)); + string finalBinary = Path.Combine(finalDir, binaryName); + + string baseUrl = Environment.GetEnvironmentVariable("BUGSEE_CLI_BASE_URL"); + string url = BugseeCliPolicy.ArchiveUrl(baseUrl, version, triple); + if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("refusing a non-https download URL " + url); + } + + string work = Path.Combine(Path.GetDirectoryName(finalDir), triple + ".part-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(work); + try + { + Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); + string archive = Path.Combine(work, BugseeCliPolicy.ArchiveName(triple)); + Fetch(url, archive); + string expected = BugseeCliPolicy.ParseSha256Sidecar(FetchText(BugseeCliPolicy.ChecksumUrl(url))); + if (expected == null) + { + throw new IOException("the published checksum for " + Path.GetFileName(archive) + " is malformed"); + } + + string actual = Sha256Hex(archive); + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("SHA-256 mismatch for " + Path.GetFileName(archive) + ": expected " + expected + ", got " + actual); + } + + string extracted = Path.Combine(work, "x"); + Directory.CreateDirectory(extracted); + Extract(archive, extracted, BugseeCliPolicy.IsWindowsTriple(triple)); + string binary = FindBinary(extracted, binaryName); + if (binary == null) + { + throw new IOException("the archive holds no " + binaryName); + } + + if (!BugseeCliPolicy.IsWindowsTriple(triple)) + { + int chmod = Capture("chmod", "+x \"" + binary + "\"", 15000, out _); + if (chmod != 0) + { + throw new IOException("chmod +x failed on " + binary); + } + } + + // A concurrent build may have installed the same release meanwhile: keep theirs. + Directory.CreateDirectory(finalDir); + if (!File.Exists(finalBinary)) + { + File.Copy(binary, finalBinary, true); + } + + if (!BugseeCliPolicy.IsWindowsTriple(triple)) + { + Capture("chmod", "+x \"" + finalBinary + "\"", 15000, out _); + } + + if (QueryVersion(finalBinary) == null) + { + try { File.Delete(finalBinary); } catch { /* ignore */ } + throw new IOException("the downloaded " + binaryName + " does not run on this machine"); + } + + return finalBinary; + } + finally + { + try { Directory.Delete(work, true); } catch { /* ignore */ } + } + } + + static void Fetch(string url, string destination) + { + try + { + var request = (HttpWebRequest)WebRequest.Create(url); + request.Timeout = 30000; + request.ReadWriteTimeout = 120000; + using (var response = (HttpWebResponse)request.GetResponse()) + using (Stream input = response.GetResponseStream()) + using (var output = File.Create(destination)) + { + input.CopyTo(output); + } + } + catch (Exception ex) + { + // Mono's TLS stack rejects some hosts' certificate chains: curl uses the system trust store. + string ignored; + int exit = Capture("curl", "--proto \"=https\" --tlsv1.2 -fsSL --max-time 300 -o \"" + destination + "\" \"" + url + "\"", 330000, out ignored); + if (exit != 0 || !File.Exists(destination)) + { + throw new IOException("GET " + url + " failed (" + ex.Message + "; curl exit " + exit + ")"); + } + } + } + + static string FetchText(string url) + { + string temp = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8) + ".sha256"); + try + { + Fetch(url, temp); + return File.ReadAllText(temp); + } + finally + { + try { File.Delete(temp); } catch { /* ignore */ } + } + } + + static string Sha256Hex(string path) + { + using (var sha = SHA256.Create()) + using (var stream = File.OpenRead(path)) + { + var hex = new StringBuilder(); + foreach (byte b in sha.ComputeHash(stream)) + { + hex.Append(b.ToString("x2")); + } + + return hex.ToString(); + } + } + + static void Extract(string archive, string destination, bool zip) + { + if (zip) + { + System.IO.Compression.ZipFile.ExtractToDirectory(archive, destination); + return; + } + + string ignored; + int exit = Capture("tar", "-xf \"" + archive + "\" -C \"" + destination + "\"", 120000, out ignored); + if (exit != 0) + { + throw new IOException("tar -xf " + Path.GetFileName(archive) + " failed (exit " + exit + "); xz support is needed to unpack it"); + } + } + + static string FindBinary(string root, string name) + { + foreach (string file in Directory.GetFiles(root, name, SearchOption.AllDirectories)) + { + return file; + } + + return null; + } + } + + /// Forgets the last resolution when a build starts, so a CLI installed since is found. + sealed class BugseeCliResolutionReset : IPreprocessBuildWithReport + { + public int callbackOrder => int.MinValue; + + public void OnPreprocessBuild(BuildReport report) => BugseeCliLocator.Reset(); + } +} +#endif diff --git a/Editor/BugseeCliLocator.cs.meta b/Editor/BugseeCliLocator.cs.meta new file mode 100644 index 0000000..e4b7dc5 --- /dev/null +++ b/Editor/BugseeCliLocator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b55aeccb10c4344a51132047441309e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BugseeCliRunner.cs b/Editor/BugseeCliRunner.cs index 821c895..d73f0bc 100644 --- a/Editor/BugseeCliRunner.cs +++ b/Editor/BugseeCliRunner.cs @@ -16,8 +16,18 @@ internal static class BugseeCliRunner { public const int DefaultTimeoutMs = 5 * 60 * 1000; - public static string CliPath => - Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH") ?? "bugsee-cli"; + /// + /// The resolved CLI (see ), or the bare name for messages when none was found: + /// the editor's PATH is often not the shell's, so a bare name alone is not enough. + /// + public static string CliPath + { + get + { + var resolved = BugseeCliLocator.Resolve(); + return resolved.Found ? resolved.Path : "bugsee-cli"; + } + } public static string AppToken => Environment.GetEnvironmentVariable("BUGSEE_APP_TOKEN"); @@ -31,6 +41,11 @@ public static int Run(IList args, int timeoutMs = DefaultTimeoutMs) return -1; } + if (BugseeCliLocator.ReportIfMissing()) + { + return -1; + } + var quoted = new string[args.Count]; for (var i = 0; i < args.Count; i++) { diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs new file mode 100644 index 0000000..7bae6c7 --- /dev/null +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// Where a resolved bugsee-cli came from. + public enum CliSource + { + None, + /// BUGSEE_CLI_PATH. + Explicit, + /// A directory on the editor's own PATH. + Path, + /// A directory the installers and package managers use, which the editor's PATH often lacks. + KnownDirectory, + /// The package's download cache. + Cache, + /// Downloaded for this build. + Downloaded, + } + + /// Inputs of : the host, the environment and the package's choices. + public sealed class CliSettings + { + /// BUGSEE_CLI_PATH: a file, or a bare command name to look up on PATH. + public string ExplicitPath; + public string PathEnv; + public string Home; + /// %LOCALAPPDATA% (Windows installers' target); null elsewhere. + public string LocalAppData; + public bool IsWindows; + /// The release to download when none is installed. + public string Version = BugseeCliPolicy.DefaultVersion; + /// An installed CLI older than this is passed over. + public string MinVersion = BugseeCliPolicy.MinVersion; + /// Release triple of this host; null when none is published. + public string Triple; + public bool DownloadAllowed; + /// Directories a download is cached under, in order of preference. + public IList CacheDirectories = new List(); + } + + /// The file system, the process runner and the downloader, injected so the policy is testable. + public sealed class CliProbe + { + public Func FileExists; + /// Runs path --version: the version it reports ("0.8.1"), or null when it does not run. + public Func QueryVersion; + /// Downloads and verifies the release into the directory; returns the binary's path. May throw. + public Func Download; + } + + public sealed class CliResolution + { + public string Path; + public CliSource Source; + public string Version; + /// What was skipped or failed on the way, for the message when nothing was found. + public readonly List Notes = new List(); + + public bool Found => Path != null; + } + + /// + /// Finds the bugsee-cli binary the Editor symbol uploads run. The Unity Editor is usually started from the + /// Hub or the Finder, not from a shell, so the PATH it inherits lacks the places the CLI is installed (Homebrew, + /// ~/.local/bin, ...): a bare bugsee-cli then fails with "Cannot find the specified file". Order: + /// BUGSEE_CLI_PATH, the PATH, the well-known install directories, the package's cache, and (when allowed) a + /// checksum-verified download of the pinned release from download.bugsee.com, as the Bugsee Gradle plugin does. + /// + public static class BugseeCliPolicy + { + /// The release downloaded when none is installed. Same default as the Bugsee Gradle plugin 4.0.8. + public const string DefaultVersion = "0.8.1"; + + /// + /// Oldest installed CLI used as is: 0.8.0 validates LineNumberMappings.json before packing it and uploads + /// ELF directories; older ones lack behaviour the uploads rely on. + /// + public const string MinVersion = "0.8.0"; + + public const string DefaultBaseUrl = "https://download.bugsee.com/cli"; + + public const string CliName = "bugsee-cli"; + + /// The release triple (as named in the published archives) of a host, or null when none is published. + public static string HostTriple(string os, string arch) + { + string o = (os ?? "").ToLowerInvariant(); + string a = (arch ?? "").ToLowerInvariant(); + bool arm64 = a == "arm64" || a == "aarch64"; + bool x64 = a == "x64" || a == "x86_64" || a == "amd64"; + bool mac = o.Contains("mac") || o.Contains("darwin") || o.Contains("osx"); + bool linux = o.Contains("linux"); + bool windows = o.Contains("windows") || o.Contains("win32"); + if (mac && arm64) return "aarch64-apple-darwin"; + if (mac && x64) return "x86_64-apple-darwin"; + if (linux && arm64) return "aarch64-unknown-linux-gnu"; + if (linux && x64) return "x86_64-unknown-linux-gnu"; + if (windows && x64) return "x86_64-pc-windows-msvc"; + return null; + } + + public static bool IsWindowsTriple(string triple) => triple != null && triple.Contains("windows"); + + public static string BinaryName(bool windows) => windows ? CliName + ".exe" : CliName; + + /// Archive extension: Windows releases are zips, the others tar.xz. + public static string ArchiveName(string triple) => + CliName + "-" + triple + (IsWindowsTriple(triple) ? ".zip" : ".tar.xz"); + + public static string ArchiveUrl(string baseUrl, string version, string triple) => + (string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl.Trim().TrimEnd('/')) + + "/v" + version + "/" + ArchiveName(triple); + + public static string ChecksumUrl(string archiveUrl) => archiveUrl + ".sha256"; + + /// The hash in a .sha256 sidecar ("<hex> *name" or "<hex> name"), lower-cased; null if malformed. + public static string ParseSha256Sidecar(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + string first = text.Trim().Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)[0]; + if (first.Length != 64) + { + return null; + } + + foreach (char c in first) + { + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) + { + return null; + } + } + + return first.ToLowerInvariant(); + } + + /// "0.8.1" from "bugsee-cli 0.8.1" (the last token that starts with a digit), or null. + public static string ParseVersionOutput(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + foreach (string token in output.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (char.IsDigit(token[0])) + { + return token; + } + } + + return null; + } + + /// True when is at least ; compares the numeric parts, ignoring any suffix. + public static bool VersionAtLeast(string version, string min) + { + int[] a = NumericParts(version); + int[] b = NumericParts(min); + for (int i = 0; i < Math.Max(a.Length, b.Length); i++) + { + int ai = i < a.Length ? a[i] : 0; + int bi = i < b.Length ? b[i] : 0; + if (ai != bi) + { + return ai > bi; + } + } + + return true; + } + + static int[] NumericParts(string version) + { + var parts = new List(); + foreach (string segment in (version ?? "").Split('.', '-', '+')) + { + int end = 0; + while (end < segment.Length && char.IsDigit(segment[end])) + { + end++; + } + + int value; + parts.Add(end > 0 && int.TryParse(segment.Substring(0, end), out value) ? value : 0); + } + + return parts.ToArray(); + } + + /// + /// Directories searched for the binary: the PATH, then where the installers and package managers put it + /// (install.sh: /usr/local/bin or ~/.local/bin; Homebrew: /opt/homebrew/bin; install.ps1: + /// %LOCALAPPDATA%\Bugsee\bin; ~/.bugsee/bin). The second element tells a PATH entry from a well-known one. + /// + public static List> SearchDirectories(CliSettings s) + { + var result = new List>(); + var seen = new HashSet(s.IsWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + char separator = s.IsWindows ? ';' : ':'; + + void Add(string dir, CliSource source) + { + dir = (dir ?? "").Trim().Trim('"'); + if (dir.Length > 1) + { + dir = dir.TrimEnd('/', '\\'); + } + + if (dir.Length > 0 && seen.Add(dir)) + { + result.Add(new KeyValuePair(dir, source)); + } + } + + foreach (string dir in (s.PathEnv ?? "").Split(separator)) + { + Add(dir, CliSource.Path); + } + + if (s.IsWindows) + { + if (!string.IsNullOrEmpty(s.LocalAppData)) + { + Add(Combine(s, s.LocalAppData, "Bugsee", "bin"), CliSource.KnownDirectory); + } + } + else + { + Add("/opt/homebrew/bin", CliSource.KnownDirectory); + Add("/usr/local/bin", CliSource.KnownDirectory); + if (!string.IsNullOrEmpty(s.Home)) + { + Add(Combine(s, s.Home, ".local", "bin"), CliSource.KnownDirectory); + Add(Combine(s, s.Home, ".cargo", "bin"), CliSource.KnownDirectory); + } + } + + if (!string.IsNullOrEmpty(s.Home)) + { + Add(Combine(s, s.Home, ".bugsee", "bin"), CliSource.KnownDirectory); + } + + return result; + } + + static string Combine(CliSettings s, params string[] parts) => + string.Join(s.IsWindows ? "\\" : "/", parts); + + /// Where a download of for lives under a cache directory. + public static string CachedBinaryPath(CliSettings s, string cacheDir, string version, string triple) => + Combine(s, cacheDir.TrimEnd('/', '\\'), version, triple, BinaryName(IsWindowsTriple(triple))); + + /// + /// Whether a missing CLI may be downloaded: the token is set (the upload is already opted in by it) and the + /// project did not opt out with BUGSEE_NO_CLI_DOWNLOAD. + /// + public static bool DownloadAllowed(bool tokenSet, bool optedOut) => tokenSet && !optedOut; + + public static CliResolution Resolve(CliSettings s, CliProbe probe) + { + var resolution = new CliResolution(); + + if (!string.IsNullOrWhiteSpace(s.ExplicitPath)) + { + string explicitPath = s.ExplicitPath.Trim(); + bool bareName = explicitPath.IndexOf('/') < 0 && explicitPath.IndexOf('\\') < 0; + if (!bareName) + { + if (probe.FileExists(explicitPath)) + { + resolution.Path = explicitPath; + resolution.Source = CliSource.Explicit; + resolution.Version = probe.QueryVersion(explicitPath); + if (resolution.Version != null && !VersionAtLeast(resolution.Version, s.MinVersion)) + { + resolution.Notes.Add( + "BUGSEE_CLI_PATH " + explicitPath + " is bugsee-cli " + resolution.Version + ", older than " + + s.MinVersion + "; uploads may be rejected (it is used because you set it)."); + } + + return resolution; + } + + resolution.Notes.Add("BUGSEE_CLI_PATH is set to " + explicitPath + ", which does not exist."); + } + else + { + // A bare command name (the old default was "bugsee-cli"): look it up like any other. + foreach (var entry in SearchDirectories(s)) + { + string candidate = Combine(s, entry.Key, explicitPath); + if (probe.FileExists(candidate)) + { + resolution.Path = candidate; + resolution.Source = CliSource.Explicit; + resolution.Version = probe.QueryVersion(candidate); + return resolution; + } + } + + resolution.Notes.Add("BUGSEE_CLI_PATH is set to the command " + explicitPath + ", which is not on PATH."); + } + } + + string name = BinaryName(s.IsWindows); + foreach (var entry in SearchDirectories(s)) + { + string candidate = Combine(s, entry.Key, name); + if (!probe.FileExists(candidate)) + { + continue; + } + + string version = probe.QueryVersion(candidate); + if (version == null) + { + resolution.Notes.Add(candidate + " exists but does not run (wrong architecture or not executable)."); + continue; + } + + if (!VersionAtLeast(version, s.MinVersion)) + { + resolution.Notes.Add(candidate + " is bugsee-cli " + version + ", older than " + s.MinVersion + "."); + continue; + } + + resolution.Path = candidate; + resolution.Source = entry.Value; + resolution.Version = version; + return resolution; + } + + if (s.Triple == null) + { + resolution.Notes.Add("No bugsee-cli release is published for this host, so none can be downloaded."); + return resolution; + } + + foreach (string cacheDir in s.CacheDirectories) + { + string cached = CachedBinaryPath(s, cacheDir, s.Version, s.Triple); + if (probe.FileExists(cached) && probe.QueryVersion(cached) != null) + { + resolution.Path = cached; + resolution.Source = CliSource.Cache; + resolution.Version = s.Version; + return resolution; + } + } + + if (!s.DownloadAllowed) + { + resolution.Notes.Add("Downloading bugsee-cli is disabled (BUGSEE_NO_CLI_DOWNLOAD is set)."); + return resolution; + } + + foreach (string cacheDir in s.CacheDirectories) + { + try + { + string downloaded = probe.Download(cacheDir, s.Version); + if (downloaded != null && probe.FileExists(downloaded)) + { + resolution.Path = downloaded; + resolution.Source = CliSource.Downloaded; + resolution.Version = s.Version; + return resolution; + } + } + catch (Exception ex) + { + resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " into " + cacheDir + " failed: " + ex.Message); + } + } + + return resolution; + } + + /// The warning shown (once per build) when no CLI could be resolved. + public static string NotFoundMessage(CliResolution resolution) + { + var text = new StringBuilder("Bugsee: bugsee-cli was not found, so symbols are NOT uploaded (the build continues). "); + foreach (string note in resolution.Notes) + { + text.Append(note).Append(' '); + } + + text.Append("Install it (curl --proto '=https' --tlsv1.2 -sSfL https://download.bugsee.com/cli/install.sh | sh) ") + .Append("or point BUGSEE_CLI_PATH at the binary."); + return text.ToString(); + } + } +} diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta b/Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta new file mode 100644 index 0000000..0fa01b0 --- /dev/null +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b141678a1e143dcb6748353bdfbd369 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs new file mode 100644 index 0000000..022c026 --- /dev/null +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class BugseeCliPolicyTests + { + // A fake host: the files that exist, the version each reports, and what a download produces. + sealed class Host + { + public readonly Dictionary Files = new Dictionary(); // path -> version ("" = does not run) + public readonly List Downloads = new List(); + public Func OnDownload; + + public CliProbe Probe() => new CliProbe + { + FileExists = Files.ContainsKey, + QueryVersion = p => Files.TryGetValue(p, out var v) && v != "" ? v : null, + Download = (dir, version) => + { + Downloads.Add(dir + "|" + version); + return OnDownload?.Invoke(dir, version); + }, + }; + } + + static CliSettings Mac(string path = "/usr/bin:/bin", bool download = true) => new CliSettings + { + PathEnv = path, + Home = "/Users/dev", + Triple = "aarch64-apple-darwin", + DownloadAllowed = download, + CacheDirectories = { "/Users/dev/Library/Caches/Bugsee/cli", "/proj/Library/Bugsee/cli" }, + }; + + [TestCase("macos", "Arm64", "aarch64-apple-darwin")] + [TestCase("macos", "X64", "x86_64-apple-darwin")] + [TestCase("Linux", "x86_64", "x86_64-unknown-linux-gnu")] + [TestCase("linux", "aarch64", "aarch64-unknown-linux-gnu")] + [TestCase("windows", "X64", "x86_64-pc-windows-msvc")] + [TestCase("windows", "Arm64", null)] + [TestCase("linux", "X86", null)] + [TestCase("", "X64", null)] + public void Host_triple_names_the_published_release(string os, string arch, string expected) + { + Assert.That(BugseeCliPolicy.HostTriple(os, arch), Is.EqualTo(expected)); + } + + [Test] + public void Archives_are_zip_on_windows_and_tar_xz_elsewhere() + { + Assert.That(BugseeCliPolicy.ArchiveUrl(null, "0.8.1", "aarch64-apple-darwin"), + Is.EqualTo("https://download.bugsee.com/cli/v0.8.1/bugsee-cli-aarch64-apple-darwin.tar.xz")); + Assert.That(BugseeCliPolicy.ArchiveUrl("https://mirror.example/cli/", "0.8.1", "x86_64-pc-windows-msvc"), + Is.EqualTo("https://mirror.example/cli/v0.8.1/bugsee-cli-x86_64-pc-windows-msvc.zip")); + Assert.That(BugseeCliPolicy.ChecksumUrl("https://x/y.tar.xz"), Is.EqualTo("https://x/y.tar.xz.sha256")); + } + + [Test] + public void Checksum_sidecar_is_read_in_binary_and_text_mode_and_malformed_ones_are_rejected() + { + string hash = new string('a', 32) + new string('B', 32); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(hash + " *bugsee-cli-x.tar.xz\n"), Is.EqualTo(hash.ToLowerInvariant())); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(hash + " bugsee-cli-x.tar.xz"), Is.EqualTo(hash.ToLowerInvariant())); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(hash), Is.EqualTo(hash.ToLowerInvariant())); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(""), Is.Null); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar("Not found"), Is.Null); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(new string('g', 64)), Is.Null); + } + + [TestCase("bugsee-cli 0.8.1\n", "0.8.1")] + [TestCase("bugsee-cli 0.9.0-rc.1", "0.9.0-rc.1")] + [TestCase("", null)] + [TestCase("error: nope", null)] + public void Version_is_read_from_the_cli_banner(string output, string expected) + { + Assert.That(BugseeCliPolicy.ParseVersionOutput(output), Is.EqualTo(expected)); + } + + [TestCase("0.8.1", "0.8.0", true)] + [TestCase("0.8.0", "0.8.0", true)] + [TestCase("0.7.13", "0.8.0", false)] + [TestCase("0.10.0", "0.8.1", true)] + [TestCase("1.0", "0.8.1", true)] + [TestCase("0.8.0-rc.1", "0.8.0", true)] + public void Version_comparison_is_numeric(string version, string min, bool expected) + { + Assert.That(BugseeCliPolicy.VersionAtLeast(version, min), Is.EqualTo(expected)); + } + + [Test] + public void Search_order_is_path_then_the_install_directories_without_duplicates() + { + var s = Mac("/usr/bin:/opt/homebrew/bin/:/usr/bin"); + + var dirs = BugseeCliPolicy.SearchDirectories(s); + + Assert.That(dirs.Select(d => d.Key), Is.EqualTo(new[] + { + "/usr/bin", "/opt/homebrew/bin", "/usr/local/bin", + "/Users/dev/.local/bin", "/Users/dev/.cargo/bin", "/Users/dev/.bugsee/bin", + })); + Assert.That(dirs[0].Value, Is.EqualTo(CliSource.Path)); + Assert.That(dirs[1].Value, Is.EqualTo(CliSource.Path), "a well-known directory that is also on PATH counts as PATH"); + Assert.That(dirs[2].Value, Is.EqualTo(CliSource.KnownDirectory)); + } + + [Test] + public void Windows_searches_localappdata_and_uses_semicolons() + { + var s = new CliSettings + { + IsWindows = true, + PathEnv = "C:\\Windows;C:\\tools", + Home = "C:\\Users\\dev", + LocalAppData = "C:\\Users\\dev\\AppData\\Local", + }; + + var dirs = BugseeCliPolicy.SearchDirectories(s).Select(d => d.Key).ToList(); + + Assert.That(dirs, Is.EqualTo(new[] + { + "C:\\Windows", "C:\\tools", "C:\\Users\\dev\\AppData\\Local\\Bugsee\\bin", "C:\\Users\\dev\\.bugsee\\bin", + })); + } + + [Test] + public void A_cli_in_a_directory_missing_from_the_editor_path_is_found() + { + // The reported failure: the Hub-started editor's PATH has no /opt/homebrew/bin, so a bare "bugsee-cli" + // could not be started. + var host = new Host(); + host.Files["/opt/homebrew/bin/bugsee-cli"] = "0.8.1"; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Found, Is.True); + Assert.That(r.Path, Is.EqualTo("/opt/homebrew/bin/bugsee-cli")); + Assert.That(r.Source, Is.EqualTo(CliSource.KnownDirectory)); + Assert.That(host.Downloads, Is.Empty); + } + + [Test] + public void Explicit_path_wins_over_everything_and_skips_the_version_gate() + { + var host = new Host(); + host.Files["/opt/homebrew/bin/bugsee-cli"] = "0.8.1"; + host.Files["/custom/bugsee-cli"] = "0.7.0"; + var s = Mac(); + s.ExplicitPath = "/custom/bugsee-cli"; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/custom/bugsee-cli")); + Assert.That(r.Source, Is.EqualTo(CliSource.Explicit)); + Assert.That(r.Notes.Single(), Does.Contain("older than 0.8.0")); + } + + [Test] + public void Explicit_path_that_does_not_exist_is_reported_and_discovery_continues() + { + var host = new Host(); + host.Files["/usr/local/bin/bugsee-cli"] = "0.8.1"; + var s = Mac(); + s.ExplicitPath = "/nope/bugsee-cli"; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/usr/local/bin/bugsee-cli")); + Assert.That(r.Notes.Single(), Does.Contain("/nope/bugsee-cli").And.Contain("does not exist")); + } + + [Test] + public void Explicit_bare_command_is_looked_up_in_every_search_directory() + { + var host = new Host(); + host.Files["/Users/dev/.local/bin/bugsee-cli"] = "0.8.1"; + var s = Mac(); + s.ExplicitPath = "bugsee-cli"; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/Users/dev/.local/bin/bugsee-cli")); + Assert.That(r.Source, Is.EqualTo(CliSource.Explicit)); + } + + [Test] + public void Old_or_broken_installs_are_passed_over_for_the_next_candidate() + { + var host = new Host(); + host.Files["/opt/homebrew/bin/bugsee-cli"] = "0.7.13"; + host.Files["/usr/local/bin/bugsee-cli"] = ""; + host.Files["/Users/dev/.bugsee/bin/bugsee-cli"] = "0.8.1"; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/Users/dev/.bugsee/bin/bugsee-cli")); + Assert.That(r.Notes, Has.Count.EqualTo(2)); + Assert.That(r.Notes[0], Does.Contain("0.7.13")); + Assert.That(r.Notes[1], Does.Contain("does not run")); + } + + [Test] + public void A_cached_download_is_used_without_downloading_again() + { + var host = new Host(); + host.Files["/Users/dev/Library/Caches/Bugsee/cli/0.8.1/aarch64-apple-darwin/bugsee-cli"] = "0.8.1"; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Source, Is.EqualTo(CliSource.Cache)); + Assert.That(host.Downloads, Is.Empty); + } + + [Test] + public void A_missing_cli_is_downloaded_into_the_first_cache_directory() + { + var host = new Host(); + host.OnDownload = (dir, version) => + { + string path = dir + "/" + version + "/aarch64-apple-darwin/bugsee-cli"; + host.Files[path] = version; + return path; + }; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Source, Is.EqualTo(CliSource.Downloaded)); + Assert.That(r.Path, Is.EqualTo("/Users/dev/Library/Caches/Bugsee/cli/0.8.1/aarch64-apple-darwin/bugsee-cli")); + Assert.That(host.Downloads, Is.EqualTo(new[] { "/Users/dev/Library/Caches/Bugsee/cli|0.8.1" })); + } + + [Test] + public void A_failing_download_falls_back_to_the_next_cache_directory_then_reports_every_failure() + { + var host = new Host(); + host.OnDownload = (dir, version) => throw new InvalidOperationException("SHA-256 mismatch in " + dir); + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Found, Is.False); + Assert.That(host.Downloads, Has.Count.EqualTo(2)); + Assert.That(r.Notes, Has.Count.EqualTo(2)); + string message = BugseeCliPolicy.NotFoundMessage(r); + Assert.That(message, Does.Contain("SHA-256 mismatch").And.Contain("BUGSEE_CLI_PATH").And.Contain("install.sh")); + Assert.That(message, Does.Contain("build continues")); + } + + [Test] + public void Download_is_not_attempted_when_it_is_not_allowed() + { + var host = new Host(); + + var r = BugseeCliPolicy.Resolve(Mac(download: false), host.Probe()); + + Assert.That(r.Found, Is.False); + Assert.That(host.Downloads, Is.Empty); + Assert.That(r.Notes.Single(), Does.Contain("BUGSEE_NO_CLI_DOWNLOAD")); + } + + [Test] + public void A_host_without_a_published_release_gets_a_note_and_no_download() + { + var host = new Host(); + var s = Mac(); + s.Triple = null; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Found, Is.False); + Assert.That(host.Downloads, Is.Empty); + Assert.That(r.Notes.Single(), Does.Contain("No bugsee-cli release is published")); + } + + [TestCase(true, false, true)] + [TestCase(true, true, false)] + [TestCase(false, false, false)] + public void Download_needs_the_token_and_respects_the_opt_out(bool token, bool optedOut, bool expected) + { + Assert.That(BugseeCliPolicy.DownloadAllowed(token, optedOut), Is.EqualTo(expected)); + } + + [Test] + public void Cache_layout_is_version_then_triple() + { + Assert.That( + BugseeCliPolicy.CachedBinaryPath(new CliSettings(), "/c", "0.8.1", "x86_64-pc-windows-msvc"), + Is.EqualTo("/c/0.8.1/x86_64-pc-windows-msvc/bugsee-cli.exe")); + } + } +} From f9845ecb340fe8502b980b071be6db33fac82188 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 20:58:12 +0500 Subject: [PATCH 2/9] Recover the Gradle plugin's build id, mapping id and icon on Unity 2021.3 / 2022.3 (AGP 7.4.2). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugsee Gradle plugin 4.0.8 needs AGP 8.6+ and cannot load on the AGP 7.4.2 those Unity versions ship. The launcher gets a small block that writes assets/bugsee_build_id.properties from the R8 mapping (checked on AGP 7.4.2 / Gradle 7.5.1: no toListenTo, and transforming ASSETS drops every library asset). Without it the SDK derives its id from token, version name and code, and the mapping and ELF uploads are keyed by that. The mapping upload also attaches the launcher icon. Docs and changelog state what is not recovered. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 3 + Documentation~/index.md | 27 ++- Editor/BugseeAndroidGradleSetup.cs | 40 ++++- Editor/BugseeBuildIdentity.cs | 18 +- Editor/BugseeSymbolUpload.cs | 55 +++++- README.md | 2 +- Runtime/WrapperPolicy/AndroidBuildUuid.cs | 52 ++++++ .../WrapperPolicy/AndroidBuildUuid.cs.meta | 11 ++ Runtime/WrapperPolicy/AndroidIconPicker.cs | 62 +++++++ .../WrapperPolicy/AndroidIconPicker.cs.meta | 11 ++ Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 167 ++++++++++++++++++ .../WrapperPolicy/GradleBuildIdBlock.cs.meta | 11 ++ .../WrapperPolicy/GradleTemplateDefaults.cs | 11 ++ Runtime/WrapperPolicy/MarkedGradleBlock.cs | 6 +- Tests~/WrapperPolicy/AndroidBuildUuidTests.cs | 32 ++++ .../WrapperPolicy/AndroidIconPickerTests.cs | 37 ++++ .../2022.3.62f3/baseProjectTemplate.gradle | 13 ++ .../2022.3.62f3/launcherTemplate.gradle | 62 +++++++ .../WrapperPolicy/GradleBuildIdBlockTests.cs | 105 +++++++++++ .../OldUnitySymbolUploadSourceTests.cs | 105 +++++++++++ 20 files changed, 816 insertions(+), 14 deletions(-) create mode 100644 Runtime/WrapperPolicy/AndroidBuildUuid.cs create mode 100644 Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta create mode 100644 Runtime/WrapperPolicy/AndroidIconPicker.cs create mode 100644 Runtime/WrapperPolicy/AndroidIconPicker.cs.meta create mode 100644 Runtime/WrapperPolicy/GradleBuildIdBlock.cs create mode 100644 Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidBuildUuidTests.cs create mode 100644 Tests~/WrapperPolicy/AndroidIconPickerTests.cs create mode 100644 Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle create mode 100644 Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs create mode 100644 Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aca7f8..7ffa0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a SHA-256-verified download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set (`BUGSEE_NO_CLI_DOWNLOAD=1` opts out, `BUGSEE_CLI_VERSION` and `BUGSEE_CLI_BASE_URL` override). A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered: the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute), so the mapping upload matches what the SDK reports and Java frames of minified builds deobfuscate. If the block cannot be added, or R8 is off, the SDK derives its own id from token, version name and version code and the mapping is uploaded under that id. The mapping upload now also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and verification in `Documentation~/index.md`. + - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. - Android Mono builds cannot symbolicate native frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`): Unity 2021.3 has no Mono ARM64 and those libraries have no GNU build-id. IL2CPP is the supported configuration for fully symbolicated native crashes. Documented in the Player Settings checklist, and a Mono Android build now logs a one-time warning at build time regardless of symbol upload settings (the build is never failed). diff --git a/Documentation~/index.md b/Documentation~/index.md index 2c0bdba..aa9f05d 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -126,7 +126,13 @@ IL2CPP **C# file/line** via LNM always needs uploaded `il2cpp-linemap` + matchin ## Symbol upload (Editor / CI) -Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`. +Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`, `BUGSEE_BUILD_UUID`. + +### Where `bugsee-cli` comes from (Android Editor uploads) + +A Unity Editor started from the Hub or the Finder does not inherit your shell's `PATH`, so a CLI installed with Homebrew or `install.sh` is often invisible to it. The Editor uploads therefore look, in order, at: `BUGSEE_CLI_PATH`; the Editor's `PATH`; the places the installers use (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`); the package's cache (`~/Library/Caches/Bugsee/cli` on macOS, `$XDG_CACHE_HOME/bugsee/cli` or `~/.cache/bugsee/cli` on Linux, `%LOCALAPPDATA%\Bugsee\cache\cli` on Windows, else `Library/Bugsee/cli` in the project). An installed CLI older than 0.8.0 is passed over. + +When none is found and a token is set, the pinned release (0.8.1, the Bugsee Gradle plugin's default) is downloaded from `https://download.bugsee.com/cli/v/bugsee-cli-.tar.xz` (`.zip` on Windows), checked against the published `.sha256`, unpacked into that cache and run once to prove it works. The checksum comes from the same host as the archive, so it detects corruption, not a compromised host: set `BUGSEE_CLI_PATH` to a binary you vetted if that matters. `BUGSEE_NO_CLI_DOWNLOAD=1` turns the download off, `BUGSEE_CLI_VERSION` pins another release and `BUGSEE_CLI_BASE_URL` points at a mirror (https only), as for `install.sh`. If nothing can be found or downloaded the build warns once, naming what was tried, and continues without uploading. The iOS Run Script still starts a bare `bugsee-cli` (or `BUGSEE_CLI_PATH`) and does not use this lookup. The iOS Run Script uploads for **every** Xcode configuration (Debug included, so a crash from a development build is symbolicated) and runs on every build that has the token in its environment, not only archives. Set `BUGSEE_SKIP_UPLOAD_ON_DEBUG=1` to skip the Debug configuration, for example for local Xcode runs. @@ -146,7 +152,24 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn 1. Scripting Backend: **IL2CPP** is the supported configuration for fully symbolicated native crashes. With Mono, frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`) cannot be symbolicated: Unity 2021.3 has no Mono ARM64 (ARMv7 only) and those libraries have no GNU build-id. Frames in `libunity.so` and `libmain.so` still symbolicate. A Mono Android build logs a one-time warning at build time (whether or not symbol upload is configured) and is never failed 2. Create symbols.zip / native debug symbols enabled 3. For primary LNM apply: native debug symbol level **FULL** (DWARF lines), not SYMBOL_TABLE-only -4. No custom Gradle templates needed: Bugsee patches the generated Gradle project (the Bugsee Gradle plugin with `ndk { enabled = true }` when it pins AGP 8.6+, i.e. Unity 6); a template of your own that pins another AGP major than your Unity's default fails the build and is reported +4. No custom Gradle templates needed: Bugsee patches the generated Gradle project (the Bugsee Gradle plugin with `ndk { enabled = true }` when it pins AGP 8.6+, i.e. Unity 6; on older AGPs, see below); a template of your own that pins another AGP major than your Unity's default fails the build and is reported + +### Unity 2021.3 and 2022.3 (no Bugsee Gradle plugin) + +Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle 8.7+ and fails to load there (checked on 2022.3.62f3: `NoClassDefFoundError: KotlinCompilerPluginSupportPlugin`, then `NoSuchMethodError` on `OutOperationRequest.toListenTo`). What it does for a Unity app, and what stands in for it: + +| Gradle plugin function | Unity 6 | Unity 2021.3 / 2022.3 | +|---|---|---| +| Declare `bugsee-android`, `bugsee-android-ndk` | Package (Maven injection) | Same | +| `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`); needs AGP 7.4 to 8.5. With R8 off no asset is written and the SDK derives its own id | +| `com.bugsee.android.BUILD_UUID` manifest meta-data (fallback id when R8 is off) | Plugin | Not added: the SDK derives `UUID(token, versionName, versionCode)` itself when it finds neither; the uploads use that id (`AndroidBuildUuid`) | +| Mapping upload (`--uuid`, `--icon`) | Package Editor upload (the plugin finds no app token in a Unity build, so its own upload skips) | Same: mapping, same id as the asset, launcher icon attached | +| Native symbol upload | Package Editor upload of Unity's `symbols.zip` (same reason) | Same | +| NDK enabled | Plugin | Not applicable: the NDK artifact is declared and the Editor uploads the symbols | +| Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | +| Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | + +Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. If you do minify, add keep rules for the Unity wrapper classes: the package's `bugsee-unity-wrapper.proguard` is not applied by Unity unless you enable Player Settings > Publishing Settings > Custom Proguard File and copy the rules into `Assets/Plugins/Android/proguard-user.txt`, and that file does not yet cover `com.bugsee.unity.UnityManagedException` or `UnityWrapperIdentity` (R8 removes them: `ClassNotFoundException` at launch or on the first managed exception). **iOS** diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 5040551..4a90105 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -28,14 +28,28 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport, IPostGenerat public int callbackOrder => 10; public const string GradlePluginVersion = "4.0.8"; + const string GradlePluginMarker = "// bugsee:gradle-plugin"; public const string SdkVersion = BugseePackageVersion.AndroidSdkVersion; const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; + /// + /// Whether the current Android build writes the mapping-derived build id into the app, either through the Bugsee + /// Gradle plugin or through (AGP too old for the plugin). Then the CLI derives + /// the same id from the mapping on its own. Otherwise the SDK derives the id from token, version name and + /// code, and the uploads must be keyed by that (). + /// + internal static bool BuildIdAssetInjected { get; private set; } + + /// The generated Gradle project of the current Android build (the folder with settings.gradle), or null. + internal static string GeneratedProjectRoot { get; private set; } + public void OnPreprocessBuild(BuildReport report) { if (report.summary.platform == BuildTarget.Android) { + BuildIdAssetInjected = false; + GeneratedProjectRoot = null; RemoveLegacyTemplates(); } } @@ -61,7 +75,8 @@ public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) { try { - PatchGeneratedProject(Path.GetDirectoryName(Path.GetFullPath(unityLibraryPath))); + GeneratedProjectRoot = Path.GetDirectoryName(Path.GetFullPath(unityLibraryPath)); + PatchGeneratedProject(GeneratedProjectRoot); } catch (Exception ex) when (!(ex is BuildFailedException)) { @@ -104,6 +119,29 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } + bool pluginPresent = apply && + patchedRoot.Contains(GradlePluginMarker) && patchedLauncher.Contains(GradlePluginMarker); + bool blockPresent = false; + if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + { + patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); + blockPresent = blockStatus != BuildIdBlockStatus.NoAnchor; + if (blockPresent) + { + Debug.Log( + "[Bugsee] Added the build id asset (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + + ": the part of the Bugsee Gradle plugin that works on this AGP."); + } + else + { + Debug.LogWarning( + "[Bugsee] No top-level android { } block in " + launcherPath + ", so the build id asset was not added. " + + "The SDK derives its own build id and the mapping is uploaded under it."); + } + } + + BuildIdAssetInjected = pluginPresent || blockPresent; + if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { File.WriteAllText(rootPath, patchedRoot); diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index c77aa45..957f1af 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -67,8 +67,9 @@ public static string ResolveBuildNumber(BuildReport report) } /// - /// Correlation BUILD_UUID for bugsee-cli --type elf --uuid. - /// Per-.so GNU build-ids remain the real symbol keys. + /// BUILD_UUID the symbol uploads are keyed by (bugsee-cli --uuid): BUGSEE_BUILD_UUID, else on Android the + /// id the SDK derives itself for an app the Gradle plugin did not build (: token, + /// version name and version code as the APK carries them, not BUGSEE_VERSION/BUGSEE_BUILD), else a random one. Per-.so GNU build-ids remain the real native symbol keys. /// public static string ResolveBuildUuid(BuildReport report) { @@ -78,7 +79,18 @@ public static string ResolveBuildUuid(BuildReport report) return env.Trim(); } - // Prefer a stable dashed UUID when BUGSEE_BUILD_UUID is unset. + if (report?.summary.platform == BuildTarget.Android) + { + var names = AndroidNameSettings(report); + var synthesized = AndroidBuildUuid.Synthesize( + BugseeCliRunner.AppToken, names.BundleVersion, names.VersionCode.ToString()); + if (synthesized != null) + { + return synthesized; + } + } + + // Prefer a stable dashed UUID when nothing above applies. return Guid.NewGuid().ToString("D"); } diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 84831fd..92dafdc 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -74,12 +74,52 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. var stem = OutputStem(report); var allowGeneric = OutputIsDirectory(report); + // The Gradle plugin (or its stand-in, GradleBuildIdBlock) writes the mapping's content id into the app, and the + // CLI derives the same id on its own. Without either the SDK reports an id built from token, version name + // and code, so the mapping is uploaded under that one or its Java frames would never be deobfuscated. + var mappingExtra = new List(); + if (!BugseeAndroidGradleSetup.BuildIdAssetInjected) + { + var buildUuid = BugseeBuildIdentity.ResolveBuildUuid(report); + mappingExtra.Add("--uuid"); + mappingExtra.Add(buildUuid); + } + + // The Gradle plugin attaches the launcher icon to the mapping (the app's icon in the dashboard). + var icon = FindLauncherIcon(); + if (icon != null) + { + Debug.Log($"Bugsee: attaching launcher icon {icon} to the mapping upload."); + mappingExtra.Add("--icon"); + mappingExtra.Add(icon); + } + UploadIfFound(roots, "*mapping.txt", "proguard", version, build, - path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric)); + path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra); // Unity "Create symbols.zip" / Gradle native debug symbols. UploadSymbolsZipOrElf(report, roots, version, build); } + static string FindLauncherIcon() + { + var root = BugseeAndroidGradleSetup.GeneratedProjectRoot; + var res = string.IsNullOrEmpty(root) ? null : Path.Combine(root, "launcher", "src", "main", "res"); + if (res == null || !Directory.Exists(res)) + { + return null; + } + + try + { + return AndroidIconPicker.Pick(Directory.EnumerateFiles(res, "app_icon.*", SearchOption.AllDirectories)); + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: launcher icon scan failed under {res}: {ex.Message}"); + return null; + } + } + static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build) { var bestZip = FindSymbolsZip(report); @@ -173,7 +213,8 @@ static void UploadIfFound( string type, string version, string build, - Func rank) + Func rank, + IList extraArgs = null) { string best = null; var bestRank = BuildArtifactChoice.Reject; @@ -217,13 +258,19 @@ static void UploadIfFound( } Debug.Log($"Bugsee: uploading {type} via {BugseeCliRunner.CliPath}: {best}"); - var exit = BugseeCliRunner.Run(new[] + var args = new List { "debug-files", "upload", best, "--type", type, "--version", version, "--build", build, - }); + }; + if (extraArgs != null) + { + args.AddRange(extraArgs); + } + + var exit = BugseeCliRunner.Run(args); if (exit != 0) { Debug.LogWarning($"Bugsee: bugsee-cli {type} upload failed (exit {exit}) — build continues."); diff --git a/README.md b/README.md index 7e11c85..26ee074 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ log warns when its version differs from the one this package pins. | `bugsee-android` | **7.4.1**, added to `unityLibrary/build.gradle` at build time (no Maven transitives) | | `bugsee-android-ndk` | Same version, declared alongside (native fatal capture for IL2CPP primary LNM) | | `mavenCentral()` | Added to the generated project repositories only if Unity's layout lacks it | -| Gradle plugin `4.0.8` | Patched into the generated Gradle project by `BugseeAndroidGradleSetup` when it pins AGP 8.6+ (Unity 6; `ndk { enabled = true }`) | +| Gradle plugin `4.0.8` | Patched into the generated Gradle project by `BugseeAndroidGradleSetup` when it pins AGP 8.6+ (Unity 6; `ndk { enabled = true }`). On Unity 2021.3 / 2022.3 (AGP 7.4.2) it cannot run; the build-id asset is added instead and symbols are uploaded by the Editor with `bugsee-cli` found or downloaded automatically, see [Documentation~/index.md](Documentation~/index.md#unity-20213-and-20223-no-bugsee-gradle-plugin) | ### Refreshing native artifacts diff --git a/Runtime/WrapperPolicy/AndroidBuildUuid.cs b/Runtime/WrapperPolicy/AndroidBuildUuid.cs new file mode 100644 index 0000000..e6edc3f --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidBuildUuid.cs @@ -0,0 +1,52 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// + /// The build identity the Bugsee Android SDK derives on its own when the app is not built by the Bugsee Gradle + /// plugin (no bugsee_build_id.properties asset, no com.bugsee.android.BUILD_UUID meta-data): the + /// third channel of BugseeEnvironment.getApplicationBuildId (SDK 7.0.0-beta13+), the MD5 name-based UUID + /// of appToken + 0x1F + versionName + 0x1F + versionCode (Java's UUID.nameUUIDFromBytes). + /// + /// Unity 2021.3 and 2022.3 cannot run the Gradle plugin (it needs AGP 8.6+), so a mapping or native symbol + /// upload from those editors has to be keyed by this value, or the server can never match it to a crash. + /// + public static class AndroidBuildUuid + { + const char Separator = '\u001f'; + + /// The SDK's synthesized build id, or null when the token or version code is missing. + public static string Synthesize(string appToken, string versionName, string versionCode) + { + if (string.IsNullOrEmpty(appToken) || string.IsNullOrEmpty(versionCode)) + { + return null; + } + + string input = appToken.Trim() + Separator + (versionName ?? "") + Separator + versionCode.Trim(); + byte[] hash; + using (var md5 = MD5.Create()) + { + hash = md5.ComputeHash(new UTF8Encoding(false).GetBytes(input)); + } + + // Java's nameUUIDFromBytes: version 3, IETF variant, rendered big-endian. + hash[6] = (byte)((hash[6] & 0x0f) | 0x30); + hash[8] = (byte)((hash[8] & 0x3f) | 0x80); + var hex = new StringBuilder(36); + for (int i = 0; i < 16; i++) + { + if (i == 4 || i == 6 || i == 8 || i == 10) + { + hex.Append('-'); + } + + hex.Append(hash[i].ToString("x2")); + } + + return hex.ToString(); + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta b/Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta new file mode 100644 index 0000000..9129479 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0848575233d243f48b22c1d07da6aa4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/AndroidIconPicker.cs b/Runtime/WrapperPolicy/AndroidIconPicker.cs new file mode 100644 index 0000000..2d1038b --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidIconPicker.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Picks the launcher icon the Bugsee Gradle plugin attaches to the mapping upload (bugsee-cli --icon, shown + /// as the app's icon in the dashboard): the highest-density raster app_icon of the generated launcher + /// module (Unity names it @mipmap/app_icon). Adaptive icons are XML and have no raster to send. + /// + public static class AndroidIconPicker + { + static readonly string[] Densities = { "ldpi", "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi" }; + static readonly string[] Extensions = { ".png", ".webp", ".jpg", ".jpeg" }; + + /// The best candidate among (paths ending mipmap-*/app_icon.png and similar), or null. + public static string Pick(IEnumerable files) + { + string best = null; + int bestRank = -1; + foreach (string file in files) + { + int rank = Rank(file); + if (rank > bestRank) + { + best = file; + bestRank = rank; + } + } + + return best; + } + + static int Rank(string file) + { + string normalized = (file ?? "").Replace('\\', '/'); + string name = Path.GetFileName(normalized); + bool raster = false; + foreach (string ext in Extensions) + { + raster |= string.Equals(name, "app_icon" + ext, StringComparison.OrdinalIgnoreCase); + } + + if (!raster) + { + return -1; + } + + string folder = Path.GetFileName(Path.GetDirectoryName(normalized) ?? ""); + for (int i = Densities.Length - 1; i >= 0; i--) + { + if (folder.EndsWith("-" + Densities[i], StringComparison.OrdinalIgnoreCase)) + { + return i + 1; + } + } + + return 0; + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidIconPicker.cs.meta b/Runtime/WrapperPolicy/AndroidIconPicker.cs.meta new file mode 100644 index 0000000..81866bf --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidIconPicker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ddd335a800a94588be5c81db0d693407 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs new file mode 100644 index 0000000..3289044 --- /dev/null +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + public enum BuildIdBlockStatus + { + /// The block was added or brought up to date. + Applied, + /// The block was already there and current. + AlreadyCurrent, + /// No top-level android { block to put it before: left alone. + NoAnchor, + } + + /// + /// The part of the Bugsee Gradle plugin that gives the app its build id, for Android Gradle plugins too old to run + /// the plugin (Unity 2021.3 / 2022.3 pin AGP 7.4.2; the plugin needs 8.6+). After R8 it writes + /// assets/bugsee_build_id.properties holding the UUID of the R8 mapping (MD5 name-based, what the plugin + /// and bugsee-cli --type proguard compute), which is what the SDK reports at runtime. The mapping upload + /// then needs no --uuid: the CLI derives the same id from the file. With no mapping (R8 off) no asset is + /// written and the SDK derives its own id (). + /// + /// Uses only API present in AGP 7.4 (artifacts.get(OBFUSCATION_MAPPING_FILE) and a generated assets source + /// directory), not toListenTo (missing in AGP 7.4) and not a transform of the ASSETS artifact (in AGP 7.4.2 + /// that replaced the merged assets and dropped every library asset, Unity's game data among them; checked on + /// AGP 7.4.2 / Gradle 7.5.1). + /// + public static class GradleBuildIdBlock + { + public const string Begin = "// bugsee:build-id begin"; + public const string End = "// bugsee:build-id end"; + public const string AssetName = "bugsee_build_id.properties"; + + public static string Snippet() => string.Join("\n", new[] + { + Begin, + "// Written by the Bugsee Unity package: the Bugsee Gradle plugin cannot run on this Android Gradle plugin.", + "abstract class BugseeResolveBuildIdTask extends DefaultTask {", + " @InputFile @Optional @PathSensitive(PathSensitivity.NONE)", + " abstract RegularFileProperty getMappingFile()", + " @OutputFile", + " abstract RegularFileProperty getIdFile()", + " @TaskAction", + " void run() {", + " def mapping = mappingFile.orNull?.asFile", + " def out = idFile.get().asFile", + " out.parentFile.mkdirs()", + " out.text = (mapping != null && mapping.isFile() && mapping.length() > 0) ? java.util.UUID.nameUUIDFromBytes(mapping.bytes).toString() : ''", + " }", + "}", + "abstract class BugseeBuildIdAssetTask extends DefaultTask {", + " @InputFile @PathSensitive(PathSensitivity.NONE)", + " abstract RegularFileProperty getIdFile()", + " @OutputDirectory", + " abstract DirectoryProperty getOutputDir()", + " @TaskAction", + " void run() {", + " def dst = outputDir.get().asFile", + " project.delete(dst)", + " dst.mkdirs()", + " def id = idFile.get().asFile.text.trim()", + " if (!id.isEmpty()) {", + " new File(dst, '" + AssetName + "').text = \"# Bugsee build identifier - auto-generated, do not edit.\\nbugsee.build_id=${id}\\n\"", + " }", + " }", + "}", + "// A failure here only costs the build id (the SDK then derives its own), never the build.", + "if (project.extensions.findByName('androidComponents') != null) {", + " androidComponents {", + " onVariants(selector().all()) { variant ->", + " try {", + " def cap = variant.name.capitalize()", + " def mapping = variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.OBFUSCATION_MAPPING_FILE.INSTANCE)", + " def resolve = tasks.register(\"resolveBugsee${cap}BuildId\", BugseeResolveBuildIdTask) {", + " it.idFile.set(layout.buildDirectory.file(\"bugsee/${variant.name}/build-id.txt\"))", + " }", + " resolve.configure { it.mappingFile.set(mapping) }", + " def asset = tasks.register(\"injectBugsee${cap}BuildId\", BugseeBuildIdAssetTask) {", + " it.idFile.set(resolve.flatMap { it.idFile })", + " }", + " variant.sources.assets.addGeneratedSourceDirectory(asset, { it.outputDir })", + " } catch (Throwable t) {", + " logger.warn(\"Bugsee: could not add the build id asset to ${variant.name}: ${t.message}\")", + " }", + " }", + " }", + "}", + End, + }); + + /// + /// Adds the block before the launcher's top-level android { line, or replaces the one a previous run + /// left (an append export, an upgraded package). + /// + public static string Apply(string launcherGradle, out BuildIdBlockStatus status) + { + if (launcherGradle == null) + { + throw new ArgumentNullException(nameof(launcherGradle)); + } + + var lines = MarkedGradleBlock.SplitLines(launcherGradle, out string newline); + var snippet = new List(Snippet().Split('\n')); + + int begin = lines.FindIndex(l => l.Trim() == Begin); + int end = begin < 0 ? -1 : lines.FindIndex(begin, l => l.Trim() == End); + if (begin >= 0 && end > begin) + { + var current = lines.GetRange(begin, end - begin + 1); + if (SameLines(current, snippet)) + { + status = BuildIdBlockStatus.AlreadyCurrent; + return launcherGradle; + } + + lines.RemoveRange(begin, end - begin + 1); + lines.InsertRange(begin, snippet); + status = BuildIdBlockStatus.Applied; + return MarkedGradleBlock.JoinLines(lines, newline, false); + } + + int depth = 0; + bool inBlockComment = false; + int anchor = -1; + for (int i = 0; i < lines.Count; i++) + { + if (!inBlockComment && depth == 0 && lines[i].TrimStart().StartsWith("android {", StringComparison.Ordinal)) + { + anchor = i; + break; + } + + MarkedGradleBlock.ScanLine(lines[i], ref depth, ref inBlockComment); + } + + if (anchor < 0) + { + status = BuildIdBlockStatus.NoAnchor; + return launcherGradle; + } + + snippet.Add(""); + lines.InsertRange(anchor, snippet); + status = BuildIdBlockStatus.Applied; + return MarkedGradleBlock.JoinLines(lines, newline, false); + } + + static bool SameLines(List a, List b) + { + if (a.Count != b.Count) + { + return false; + } + + for (int i = 0; i < a.Count; i++) + { + if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + } +} diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta new file mode 100644 index 0000000..85ff7be --- /dev/null +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8b44ea53f574cd6b62819395a8f4272 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 6d01491..44c73c1 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -91,6 +91,17 @@ public static bool AgpMeetsBugseePlugin(string rootBuildFile) (major > 8 || (major == 8 && minor >= 6)); } + /// + /// needs the AGP 7.4 artifacts API (checked on 7.4.2) and is for versions the + /// plugin cannot run on (below 8.6): 7.4 up to 8.5. False for older AGPs (Unity 2021.3's first patch releases + /// ship AGP 4.0.1, which has no androidComponents) and when the version is unknown. + /// + public static bool AgpSupportsBuildIdBlock(string rootBuildFile) + { + return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && + ((major == 7 && minor >= 4) || (major == 8 && minor < 6)); + } + const string PluginMarker = "bugsee:gradle-plugin"; // The baseProjectTemplate.gradle earlier package versions wrote on every Unity line (Unity 2021.3's default diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs index 33146c7..8f2de92 100644 --- a/Runtime/WrapperPolicy/MarkedGradleBlock.cs +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -469,7 +469,7 @@ static void ReplaceLines(List lines, int start, int end, List re lines.InsertRange(start, replacement); } - static string JoinLines(List lines, string newline, bool trailingNewline) + internal static string JoinLines(List lines, string newline, bool trailingNewline) { var sb = new StringBuilder(); for (int i = 0; i < lines.Count; i++) @@ -648,7 +648,7 @@ static bool IsActiveBugseePluginLine(string line) trimmed.IndexOf("apply plugin", StringComparison.Ordinal) >= 0; } - static List SplitLines(string text, out string newline) + internal static List SplitLines(string text, out string newline) { newline = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; var lines = new List(); @@ -678,7 +678,7 @@ static List SplitLines(string text, out string newline) return lines; } - static void ScanLine(string line, ref int depth, ref bool inBlockComment) + internal static void ScanLine(string line, ref int depth, ref bool inBlockComment) { for (int i = 0; i < line.Length; i++) { diff --git a/Tests~/WrapperPolicy/AndroidBuildUuidTests.cs b/Tests~/WrapperPolicy/AndroidBuildUuidTests.cs new file mode 100644 index 0000000..fddf832 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidBuildUuidTests.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidBuildUuidTests + { + // Expected values come from the SDK's own algorithm: java.util.UUID.nameUUIDFromBytes( + // (token + "\u001f" + versionName + "\u001f" + versionCode).getBytes(UTF_8)). + [TestCase("sampletoken", "1.2.3", "45", "0f5c0792-affb-3962-af95-d6e66c5828e2")] + [TestCase("tok", "0.1.0", "1", "5d8da6e2-8821-3d5b-88f9-b76fd271ed4e")] + [TestCase("tökén", "1.0-é", "2147483647", "62fe7fa8-3bfd-3ff4-b6d5-141c321f240c")] + public void Matches_the_android_sdks_synthesized_build_id(string token, string versionName, string versionCode, string expected) + { + Assert.That(AndroidBuildUuid.Synthesize(token, versionName, versionCode), Is.EqualTo(expected)); + } + + [Test] + public void Whitespace_around_the_token_and_code_does_not_change_the_id() + { + Assert.That(AndroidBuildUuid.Synthesize(" tok\n", "0.1.0", " 1 "), Is.EqualTo("5d8da6e2-8821-3d5b-88f9-b76fd271ed4e")); + } + + [Test] + public void Missing_token_or_code_yields_none_and_a_missing_name_counts_as_empty() + { + Assert.That(AndroidBuildUuid.Synthesize(null, "1", "1"), Is.Null); + Assert.That(AndroidBuildUuid.Synthesize("", "1", "1"), Is.Null); + Assert.That(AndroidBuildUuid.Synthesize("t", "1", null), Is.Null); + Assert.That(AndroidBuildUuid.Synthesize("t", null, "1"), Is.EqualTo(AndroidBuildUuid.Synthesize("t", "", "1"))); + } + } +} diff --git a/Tests~/WrapperPolicy/AndroidIconPickerTests.cs b/Tests~/WrapperPolicy/AndroidIconPickerTests.cs new file mode 100644 index 0000000..e04a759 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidIconPickerTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidIconPickerTests + { + [Test] + public void The_highest_density_raster_app_icon_wins() + { + var picked = AndroidIconPicker.Pick(new[] + { + "/p/res/mipmap-mdpi/app_icon.png", + "/p/res/mipmap-xxhdpi/app_icon.png", + "/p/res/mipmap-hdpi/app_icon.png", + "/p/res/mipmap-anydpi-v26/app_icon.xml", + "/p/res/mipmap-xxxhdpi/other.png", + }); + + Assert.That(picked, Is.EqualTo("/p/res/mipmap-xxhdpi/app_icon.png")); + } + + [Test] + public void Adaptive_xml_icons_and_other_names_are_not_sent() + { + Assert.That(AndroidIconPicker.Pick(new[] { "/p/res/mipmap-anydpi-v26/app_icon.xml", "/p/res/drawable/logo.png" }), Is.Null); + Assert.That(AndroidIconPicker.Pick(new string[0]), Is.Null); + } + + [Test] + public void An_unqualified_folder_ranks_below_any_density_and_windows_paths_work() + { + var picked = AndroidIconPicker.Pick(new[] { "C:\\p\\res\\mipmap\\app_icon.webp", "C:\\p\\res\\mipmap-hdpi\\app_icon.webp" }); + + Assert.That(picked, Is.EqualTo("C:\\p\\res\\mipmap-hdpi\\app_icon.webp")); + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle new file mode 100644 index 0000000..fd43460 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle @@ -0,0 +1,13 @@ +plugins { + // If you are changing the Android Gradle Plugin version, make sure it is compatible with the Gradle version preinstalled with Unity + // See which Gradle version is preinstalled with Unity here https://docs.unity3d.com/Manual/android-gradle-overview.html + // See official Gradle and Android Gradle Plugin compatibility table here https://developer.android.com/studio/releases/gradle-plugin#updating-gradle + // To specify a custom Gradle version in Unity, go do "Preferences > External Tools", uncheck "Gradle Installed with Unity (recommended)" and specify a path to a custom Gradle version + id 'com.android.application' version '7.4.2' apply false + id 'com.android.library' version '7.4.2' apply false + **BUILD_SCRIPT_DEPS** +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle new file mode 100644 index 0000000..204dd73 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle @@ -0,0 +1,62 @@ +apply plugin: 'com.android.application' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "**NAMESPACE**" + ndkPath "**NDKPATH**" + + compileSdkVersion **APIVERSION** + buildToolsVersion '**BUILDTOOLS**' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion **MINSDKVERSION** + targetSdkVersion **TARGETSDKVERSION** + applicationId '**APPLICATIONID**' + ndk { + abiFilters **ABIFILTERS** + } + versionCode **VERSIONCODE** + versionName '**VERSIONNAME**' + } + + aaptOptions { + noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + }**SIGN** + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled **MINIFY_DEBUG** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG** + jniDebuggable true + } + release { + minifyEnabled **MINIFY_RELEASE** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG** + } + }**PACKAGING_OPTIONS****PLAY_ASSET_PACKS****SPLITS** +**BUILT_APK_LOCATION** + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +}**SPLITS_VERSION_CODE****LAUNCHER_SOURCE_BUILD_SETUP** diff --git a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs new file mode 100644 index 0000000..f527ed8 --- /dev/null +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -0,0 +1,105 @@ +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class GradleBuildIdBlockTests + { + static string Template(string unity, string name) => + File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "UnityTemplates", unity, name)); + + [TestCase("2021.3.45f1")] + [TestCase("2022.3.62f3")] + public void The_block_goes_before_the_android_block_after_the_application_plugin(string unity) + { + var launcher = Template(unity, "launcherTemplate.gradle"); + + var patched = GradleBuildIdBlock.Apply(launcher, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.Applied)); + int apply = patched.IndexOf("apply plugin: 'com.android.application'"); + int block = patched.IndexOf(GradleBuildIdBlock.Begin); + int end = patched.IndexOf(GradleBuildIdBlock.End); + int android = patched.IndexOf("\nandroid {"); + Assert.That(apply, Is.GreaterThanOrEqualTo(0)); + Assert.That(block, Is.GreaterThan(apply)); + Assert.That(end, Is.GreaterThan(block)); + Assert.That(android, Is.GreaterThan(end)); + Assert.That(patched.Replace(GradleBuildIdBlock.Snippet() + "\n\n", ""), Is.EqualTo(launcher), "only the block is added"); + } + + [Test] + public void Applying_twice_changes_nothing() + { + var once = GradleBuildIdBlock.Apply(Template("2022.3.62f3", "launcherTemplate.gradle"), out _); + + var twice = GradleBuildIdBlock.Apply(once, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.AlreadyCurrent)); + Assert.That(twice, Is.EqualTo(once)); + } + + [Test] + public void A_block_from_an_earlier_package_version_is_replaced_in_place() + { + var launcher = Template("2022.3.62f3", "launcherTemplate.gradle"); + var once = GradleBuildIdBlock.Apply(launcher, out _); + var stale = once.Replace("bugsee.build_id=", "bugsee.old_id="); + + var fixedUp = GradleBuildIdBlock.Apply(stale, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.Applied)); + Assert.That(fixedUp, Is.EqualTo(once)); + } + + [Test] + public void A_launcher_without_a_top_level_android_block_is_left_alone() + { + const string text = "apply plugin: 'com.android.application'\n// android {\n/* android {\n*/\ndependencies {\n android {\n }\n}\n"; + + var patched = GradleBuildIdBlock.Apply(text, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.NoAnchor)); + Assert.That(patched, Is.EqualTo(text)); + } + + [Test] + public void Crlf_launchers_keep_their_line_endings() + { + var crlf = Template("2022.3.62f3", "launcherTemplate.gradle").Replace("\r\n", "\n").Replace("\n", "\r\n"); + + var patched = GradleBuildIdBlock.Apply(crlf, out _); + + Assert.That(patched.Replace("\r\n", ""), Does.Not.Contain("\n")); + } + + [Test] + public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4() + { + var snippet = GradleBuildIdBlock.Snippet(); + + Assert.That(snippet, Does.Contain("project.extensions.findByName('androidComponents') != null")); + Assert.That(snippet, Does.Contain("catch (Throwable t)")); + Assert.That(snippet, Does.Contain("variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.OBFUSCATION_MAPPING_FILE.INSTANCE)")); + Assert.That(snippet, Does.Not.Contain("toListenTo"), "AGP 7.4.2 has no toListenTo"); + Assert.That(snippet, Does.Not.Contain("toTransform"), "transforming ASSETS on AGP 7.4.2 drops every library asset, Unity's game data included"); + Assert.That(snippet, Does.Contain("variant.sources.assets.addGeneratedSourceDirectory(asset")); + Assert.That(snippet, Does.Contain("bugsee_build_id.properties")); + Assert.That(snippet, Does.Contain("bugsee.build_id=${id}")); + Assert.That(snippet.Split('\n').Count(l => l.StartsWith("import ")), Is.Zero); + } + + [TestCase("id 'com.android.application' version '7.4.2' apply false", true)] + [TestCase("id 'com.android.application' version '7.1.2' apply false", false)] + [TestCase("id 'com.android.application' version '8.3.0' apply false", true)] + [TestCase("id 'com.android.application' version '8.6.0' apply false", false)] + [TestCase("id 'com.android.application' version '9.0.0' apply false", false)] + [TestCase("classpath 'com.android.tools.build:gradle:4.0.1'", false)] + [TestCase("no agp here", false)] + public void The_block_is_for_agp_7_4_up_to_the_plugins_own_minimum(string root, bool expected) + { + Assert.That(GradleTemplateDefaults.AgpSupportsBuildIdBlock(root), Is.EqualTo(expected)); + } + } +} diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs new file mode 100644 index 0000000..5e101a4 --- /dev/null +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The Editor scripts are compiled only by Unity, so the wiring the policy classes depend on is checked in source: + /// the upload resolves its CLI through the locator, and a mapping is keyed by the SDK id when no build id asset + /// runs (Unity 2021.3 / 2022.3). + /// + public class OldUnitySymbolUploadSourceTests + { + static string Source(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return File.ReadAllText(Path.Combine(dir, relative)); + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing: " + signature); + int brace = source.IndexOf('{', start); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) + return source.Substring(start, i - start + 1); + } + + Assert.Fail("Unclosed body: " + signature); + return null; + } + + [Test] + public void The_runner_starts_the_resolved_binary_and_never_the_bare_name() + { + var source = Source("Editor/BugseeCliRunner.cs"); + + Assert.That(Body(source, "public static string CliPath"), Does.Contain("BugseeCliLocator.Resolve()")); + Assert.That(Body(source, "public static int Run("), Does.Contain("BugseeCliLocator.ReportIfMissing()")); + Assert.That(source, Does.Not.Contain("?? \"bugsee-cli\"")); + } + + [Test] + public void The_locator_downloads_only_over_https_and_checks_the_published_checksum() + { + var body = Body(Source("Editor/BugseeCliLocator.cs"), "static string Download("); + + Assert.That(body, Does.Contain("StartsWith(\"https://\"")); + Assert.That(body, Does.Contain("BugseeCliPolicy.ParseSha256Sidecar(")); + Assert.That(body, Does.Contain("Sha256Hex(archive)")); + Assert.That(body, Does.Contain("SHA-256 mismatch")); + } + + [Test] + public void The_locator_forgets_a_failed_lookup_when_the_next_build_starts() + { + var source = Source("Editor/BugseeCliLocator.cs"); + + Assert.That(source, Does.Contain("class BugseeCliResolutionReset : IPreprocessBuildWithReport")); + Assert.That(source, Does.Contain("public void OnPreprocessBuild(BuildReport report) => BugseeCliLocator.Reset();")); + } + + [Test] + public void The_mapping_upload_carries_the_sdk_build_id_unless_the_gradle_plugin_runs() + { + var body = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadAndroidSymbols("); + + Assert.That(body, Does.Contain("if (!BugseeAndroidGradleSetup.BuildIdAssetInjected)")); + Assert.That(body, Does.Contain("mappingExtra.Add(\"--uuid\")")); + Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveBuildUuid(report)")); + Assert.That(body, Does.Contain("mappingExtra.Add(\"--icon\")"), "the plugin attaches the launcher icon to the mapping"); + Assert.That(Body(Source("Editor/BugseeSymbolUpload.cs"), "static string FindLauncherIcon("), Does.Contain("AndroidIconPicker.Pick(")); + } + + [Test] + public void The_android_build_id_is_the_one_the_sdk_derives_from_the_app_as_built() + { + var body = Body(Source("Editor/BugseeBuildIdentity.cs"), "public static string ResolveBuildUuid("); + + Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); + Assert.That(body, Does.Contain("names.BundleVersion")); + Assert.That(body, Does.Contain("names.VersionCode")); + } + + [Test] + public void The_gradle_setup_records_whether_the_build_id_asset_was_added_for_this_build_only() + { + var source = Source("Editor/BugseeAndroidGradleSetup.cs"); + + Assert.That(Body(source, "public void OnPreprocessBuild("), Does.Contain("BuildIdAssetInjected = false")); + Assert.That(Body(source, "static void PatchGeneratedProject("), Does.Contain("BuildIdAssetInjected = pluginPresent || blockPresent")); + } + } +} From fc209836d6f56683f215473bee40f937c090001e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:08:55 +0500 Subject: [PATCH 3/9] Validate BUGSEE_CLI_VERSION before it reaches the download URL and cache path. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeCliLocator.cs | 9 ++++++++- Runtime/WrapperPolicy/BugseeCliPolicy.cs | 8 ++++++++ Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 13 +++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs index 61f10a2..c589a61 100644 --- a/Editor/BugseeCliLocator.cs +++ b/Editor/BugseeCliLocator.cs @@ -112,6 +112,13 @@ static CliSettings ReadSettings() string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string localAppData = IsWindows ? Environment.GetEnvironmentVariable("LOCALAPPDATA") : null; string version = Environment.GetEnvironmentVariable("BUGSEE_CLI_VERSION"); + version = string.IsNullOrWhiteSpace(version) ? null : version.Trim().TrimStart('v'); + if (version != null && !BugseeCliPolicy.IsValidVersion(version)) + { + Debug.LogWarning("[Bugsee] Ignoring BUGSEE_CLI_VERSION=" + version + ": not a release version like 0.8.1. Using " + BugseeCliPolicy.DefaultVersion + "."); + version = null; + } + var settings = new CliSettings { ExplicitPath = Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH"), @@ -119,7 +126,7 @@ static CliSettings ReadSettings() Home = home, LocalAppData = localAppData, IsWindows = IsWindows, - Version = string.IsNullOrWhiteSpace(version) ? BugseeCliPolicy.DefaultVersion : version.Trim().TrimStart('v'), + Version = version ?? BugseeCliPolicy.DefaultVersion, Triple = BugseeCliPolicy.HostTriple(HostOs, RuntimeInformation.OSArchitecture.ToString()), DownloadAllowed = BugseeCliPolicy.DownloadAllowed( BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs index 7bae6c7..95f0844 100644 --- a/Runtime/WrapperPolicy/BugseeCliPolicy.cs +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -102,6 +102,14 @@ public static string HostTriple(string os, string arch) return null; } + /// + /// A release version safe to put in a URL and a cache path: digits and dots with an optional "-"/"+" suffix. + /// BUGSEE_CLI_VERSION is validated with this so a value like "../.." cannot leave the cache directory. + /// + public static bool IsValidVersion(string version) => + version != null && System.Text.RegularExpressions.Regex.IsMatch( + version, @"^[0-9]+(\.[0-9]+)*([-+][0-9A-Za-z.]+)?$"); + public static bool IsWindowsTriple(string triple) => triple != null && triple.Contains("windows"); public static string BinaryName(bool windows) => windows ? CliName + ".exe" : CliName; diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs index 022c026..489bbe7 100644 --- a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -282,6 +282,19 @@ public void Download_needs_the_token_and_respects_the_opt_out(bool token, bool o Assert.That(BugseeCliPolicy.DownloadAllowed(token, optedOut), Is.EqualTo(expected)); } + [TestCase("0.8.1", true)] + [TestCase("1.2", true)] + [TestCase("0.9.0-rc.1", true)] + [TestCase("../..", false)] + [TestCase("0.8.1/../../x", false)] + [TestCase("", false)] + [TestCase("v0.8.1", false)] + [TestCase(null, false)] + public void Only_release_versions_may_reach_a_path_or_url(string version, bool expected) + { + Assert.That(BugseeCliPolicy.IsValidVersion(version), Is.EqualTo(expected)); + } + [Test] public void Cache_layout_is_version_then_triple() { From 15dce471d81898461c92256a065db9b16786dff7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:22:50 +0500 Subject: [PATCH 4/9] Skip an empty R8 mapping instead of uploading it under the CLI's content id. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeSymbolUpload.cs | 13 +++++++++++++ .../OldUnitySymbolUploadSourceTests.cs | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 92dafdc..b72649e 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -233,6 +233,13 @@ static void UploadIfFound( { continue; } + // R8 writes an empty mapping when nothing was obfuscated. The Gradle plugin (and the build id + // block) write no id for it, so the app reports the SDK's own id and an upload keyed by the CLI's + // content id could never match: skip it. + if (IsEmptyFile(file)) + { + continue; + } var fileRank = rank(file); if (fileRank == BuildArtifactChoice.Reject) { @@ -385,6 +392,12 @@ static List CollectRoots(BuildReport report) return roots; } + static bool IsEmptyFile(string path) + { + try { return new FileInfo(path).Length == 0; } + catch { return false; } + } + static bool IsExcludedPath(string path) { var norm = path.Replace('\\', '/'); diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index 5e101a4..c8dd506 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -83,6 +83,15 @@ public void The_mapping_upload_carries_the_sdk_build_id_unless_the_gradle_plugin Assert.That(Body(Source("Editor/BugseeSymbolUpload.cs"), "static string FindLauncherIcon("), Does.Contain("AndroidIconPicker.Pick(")); } + [Test] + public void An_empty_mapping_is_never_uploaded() + { + var source = Source("Editor/BugseeSymbolUpload.cs"); + + Assert.That(Body(source, "static void UploadIfFound("), Does.Contain("if (IsEmptyFile(file))")); + Assert.That(Body(source, "static bool IsEmptyFile("), Does.Contain("new FileInfo(path).Length == 0")); + } + [Test] public void The_android_build_id_is_the_one_the_sdk_derives_from_the_app_as_built() { From 8397419ababa8285b4949f288f164834ac381a70 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:58:08 +0500 Subject: [PATCH 5/9] Read the build id back from the built app, harden the CLI download and install, narrow the Gradle block to AGP 7.4.x. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: the upload id comes from the APK/AAB asset (else the SDK's synthesized id with a warning) instead of from whether the Gradle block was added; the same id keys the ELF upload; one bounded download with pinned SHA-256 for 0.8.1, atomic cache install, no curl retry after an HTTP error, failure remembered per Editor session; no Task.project at execution time; version/code read from the generated launcher; ArgumentList; .exe lookup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 4 +- Documentation~/index.md | 22 +- Editor/BugseeAndroidGradleSetup.cs | 28 +- Editor/BugseeBuildIdentity.cs | 63 +++-- Editor/BugseeCliLocator.cs | 251 +++++++++++++----- Editor/BugseeCliRunner.cs | 14 +- Editor/BugseeSymbolUpload.cs | 44 ++- .../WrapperPolicy/AndroidUploadIdentity.cs | 124 +++++++++ .../AndroidUploadIdentity.cs.meta | 11 + Runtime/WrapperPolicy/BugseeCliPolicy.cs | 96 +++++-- Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 27 +- .../WrapperPolicy/GradleTemplateDefaults.cs | 26 +- .../AndroidUploadIdentityTests.cs | 97 +++++++ Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 91 ++++++- .../WrapperPolicy/GradleBuildIdBlockTests.cs | 23 +- .../OldUnitySymbolUploadSourceTests.cs | 69 ++--- 16 files changed, 760 insertions(+), 230 deletions(-) create mode 100644 Runtime/WrapperPolicy/AndroidUploadIdentity.cs create mode 100644 Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a52695..ddaa9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a SHA-256-verified download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set (`BUGSEE_NO_CLI_DOWNLOAD=1` opts out, `BUGSEE_CLI_VERSION` and `BUGSEE_CLI_BASE_URL` override). A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered: the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute), so the mapping upload matches what the SDK reports and Java frames of minified builds deobfuscate. If the block cannot be added, or R8 is off, the SDK derives its own id from token, version name and version code and the mapping is uploaded under that id. The mapping upload now also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and verification in `Documentation~/index.md`. +- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (else the file the block writes under `launcher/build/bugsee/`) and uploads the mapping and the native symbols under it, with `--uuid` always explicit. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. diff --git a/Documentation~/index.md b/Documentation~/index.md index 8e78745..d4ca70e 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -161,14 +161,30 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle | Gradle plugin function | Unity 6 | Unity 2021.3 / 2022.3 | |---|---|---| | Declare `bugsee-android`, `bugsee-android-ndk` | Package (Maven injection) | Same | -| `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`); needs AGP 7.4 to 8.5. With R8 off no asset is written and the SDK derives its own id | -| `com.bugsee.android.BUILD_UUID` manifest meta-data (fallback id when R8 is off) | Plugin | Not added: the SDK derives `UUID(token, versionName, versionCode)` itself when it finds neither; the uploads use that id (`AndroidBuildUuid`) | -| Mapping upload (`--uuid`, `--icon`) | Package Editor upload (the plugin finds no app token in a Unity build, so its own upload skips) | Same: mapping, same id as the asset, launcher icon attached | +| `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`), for AGP 7.4.x only (the one line it was run on; other AGPs below 8.6 get no block). With R8 off, or no block, no asset is written and the SDK derives its own id | +| `com.bugsee.android.BUILD_UUID` manifest meta-data (fallback id when R8 is off) | Plugin | Not added: the SDK derives `UUID(token, versionName, versionCode)` itself when it finds neither; the uploads use that id (`AndroidBuildUuid`) when the built app has no asset | +| Mapping upload (`--uuid`, `--icon`) | Package Editor upload (the plugin finds no app token in a Unity build, so its own upload skips) | Same, keyed by the id read back from the built APK/AAB's asset (never assumed from the build script), else the SDK's synthesized id with a warning; launcher icon attached | | Native symbol upload | Package Editor upload of Unity's `symbols.zip` (same reason) | Same | | NDK enabled | Plugin | Not applicable: the NDK artifact is declared and the Editor uploads the symbols | | Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | | Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | +The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; split-APK version-code schemes and `versionNameSuffix` set outside Unity can still differ: set `BUGSEE_BUILD_UUID` then). `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding (and removes an earlier) asset block, to use the derived id only. With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. + +Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, uploads to UNITYTA; the generated launcher's Gradle ran AGP 7.4.2 / Gradle 7.5.1 on both): + +| Unity | Build | R8 | Result | +|---|---|---|---| +| 2022.3.62f3 | release | off | builds; no asset (nothing to match), game assets intact (24 `assets/bin` entries), no mapping uploaded, native symbols uploaded | +| 2022.3.62f3 | development | off | same | +| 2022.3.62f3 | development | on | builds; APK asset id equals the uploaded mapping id | +| 2022.3.62f3 | release | on | same, plus emulator native, Java and managed crashes symbolicated | +| 2022.3.62f3 | release | on, `BUGSEE_NO_GRADLE_BUILD_ID=1` | builds; no asset; mapping uploaded under the synthesized id with the warning; the Java crash on an emulator grouped with the symbolicated issue | +| 2021.3.45f1 | release | off, development off | builds, as above | +| 2021.3.45f1 | release, development | on | builds; APK asset id equals the uploaded mapping id | + +Not verified: AGP 7.5 to 8.5 (no block is added there by design), Unity 2021.3 releases with AGP below 7.4, AAB output, build with Gradle configuration cache (the block avoids `Task.project` at execution time but was run without it), and Windows/Linux Editors. + Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. If you do minify, add keep rules for the Unity wrapper classes: the package's `bugsee-unity-wrapper.proguard` is not applied by Unity unless you enable Player Settings > Publishing Settings > Custom Proguard File and copy the rules into `Assets/Plugins/Android/proguard-user.txt`, and that file does not yet cover `com.bugsee.unity.UnityManagedException` or `UnityWrapperIdentity` (R8 removes them: `ClassNotFoundException` at launch or on the first managed exception). **iOS** diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 4a90105..f2e1898 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -33,14 +33,6 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport, IPostGenerat const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; - /// - /// Whether the current Android build writes the mapping-derived build id into the app, either through the Bugsee - /// Gradle plugin or through (AGP too old for the plugin). Then the CLI derives - /// the same id from the mapping on its own. Otherwise the SDK derives the id from token, version name and - /// code, and the uploads must be keyed by that (). - /// - internal static bool BuildIdAssetInjected { get; private set; } - /// The generated Gradle project of the current Android build (the folder with settings.gradle), or null. internal static string GeneratedProjectRoot { get; private set; } @@ -48,7 +40,6 @@ public void OnPreprocessBuild(BuildReport report) { if (report.summary.platform == BuildTarget.Android) { - BuildIdAssetInjected = false; GeneratedProjectRoot = null; RemoveLegacyTemplates(); } @@ -119,18 +110,19 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } - bool pluginPresent = apply && - patchedRoot.Contains(GradlePluginMarker) && patchedLauncher.Contains(GradlePluginMarker); - bool blockPresent = false; - if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") != null) + { + // Opt-out (the Unity version regenerates only what changed, so a block from an earlier build is removed). + patchedLauncher = GradleBuildIdBlock.Remove(patchedLauncher); + } + else if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) { patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); - blockPresent = blockStatus != BuildIdBlockStatus.NoAnchor; - if (blockPresent) + if (blockStatus != BuildIdBlockStatus.NoAnchor) { Debug.Log( - "[Bugsee] Added the build id asset (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + - ": the part of the Bugsee Gradle plugin that works on this AGP."); + "[Bugsee] Added the build id asset task (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + + ": the part of the Bugsee Gradle plugin that works on this AGP. The upload uses whatever id the built app carries."); } else { @@ -140,8 +132,6 @@ static void PatchGeneratedProject(string projectRoot) } } - BuildIdAssetInjected = pluginPresent || blockPresent; - if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { File.WriteAllText(rootPath, patchedRoot); diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 957f1af..1cb4605 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -67,31 +67,64 @@ public static string ResolveBuildNumber(BuildReport report) } /// - /// BUILD_UUID the symbol uploads are keyed by (bugsee-cli --uuid): BUGSEE_BUILD_UUID, else on Android the - /// id the SDK derives itself for an app the Gradle plugin did not build (: token, - /// version name and version code as the APK carries them, not BUGSEE_VERSION/BUGSEE_BUILD), else a random one. Per-.so GNU build-ids remain the real native symbol keys. + /// The build id an Android symbol upload is keyed by: the one the built app reports. Read back from what Gradle + /// produced (the APK/AAB's assets/bugsee_build_id.properties), with + /// the id the SDK derives itself () as the fallback, see + /// . BUGSEE_BUILD_UUID overrides. Per-.so GNU build-ids remain the real native + /// symbol keys; this id is what ties a mapping to a session (and groups the ELF upload with it). /// - public static string ResolveBuildUuid(BuildReport report) + public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) { - var env = Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"); - if (!string.IsNullOrEmpty(env)) + var names = AndroidNameSettings(report); + string name = names.BundleVersion; + string code = names.VersionCode.ToString(); + // What the APK carries, when the generated launcher says so plainly (split APK codes, overrides). + var root = BugseeAndroidGradleSetup.GeneratedProjectRoot; + var launcher = string.IsNullOrEmpty(root) ? null : Path.Combine(root, "launcher", "build.gradle"); + if (launcher != null && File.Exists(launcher)) { - return env.Trim(); + GradleTemplateDefaults.ReadGradleVersion(File.ReadAllText(launcher), out var gradleName, out var gradleCode); + name = gradleName ?? name; + code = gradleCode ?? code; } - if (report?.summary.platform == BuildTarget.Android) + return AndroidUploadIdentity.Decide( + Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"), + ReadAppAssetId(report?.summary.outputPath), + AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code)); + } + + /// The id in the APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). + static string ReadAppAssetId(string outputPath) + { + try { - var names = AndroidNameSettings(report); - var synthesized = AndroidBuildUuid.Synthesize( - BugseeCliRunner.AppToken, names.BundleVersion, names.VersionCode.ToString()); - if (synthesized != null) + if (string.IsNullOrEmpty(outputPath) || !File.Exists(outputPath)) { - return synthesized; + return null; } + + using (var zip = System.IO.Compression.ZipFile.OpenRead(outputPath)) + { + foreach (var entry in zip.Entries) + { + if (entry.FullName == "assets/bugsee_build_id.properties" || + entry.FullName == "base/assets/bugsee_build_id.properties") + { + using (var reader = new StreamReader(entry.Open())) + { + return AndroidUploadIdentity.ParseAssetProperties(reader.ReadToEnd()); + } + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: could not read the build id asset from {outputPath}: {ex.Message}"); } - // Prefer a stable dashed UUID when nothing above applies. - return Guid.NewGuid().ToString("D"); + return null; } /// diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs index c589a61..cbf1c26 100644 --- a/Editor/BugseeCliLocator.cs +++ b/Editor/BugseeCliLocator.cs @@ -27,6 +27,9 @@ internal static class BugseeCliLocator { static CliResolution _resolved; static bool _notFoundReported; + // Kept for the editor session (Reset does not clear it): a blackholed network must cost one timeout, not one per build. + static string _downloadFailure; + static string _stagingDir; public static CliResolution Resolve() { @@ -42,7 +45,9 @@ public static CliResolution Resolve() { FileExists = File.Exists, QueryVersion = QueryVersion, - Download = (cacheDir, version) => Download(settings, cacheDir, version), + Fetch = version => Fetch(settings, version), + Install = (staged, cacheDir, version) => Install(settings, staged, cacheDir, version), + Discard = _ => DiscardStaging(), }); } catch (Exception ex) @@ -118,6 +123,11 @@ static CliSettings ReadSettings() Debug.LogWarning("[Bugsee] Ignoring BUGSEE_CLI_VERSION=" + version + ": not a release version like 0.8.1. Using " + BugseeCliPolicy.DefaultVersion + "."); version = null; } + else if (version != null && !BugseeCliPolicy.VersionAtLeast(version, BugseeCliPolicy.MinVersion)) + { + Debug.LogWarning("[Bugsee] Ignoring BUGSEE_CLI_VERSION=" + version + ": older than " + BugseeCliPolicy.MinVersion + ", which the uploads need. Using " + BugseeCliPolicy.DefaultVersion + "."); + version = null; + } var settings = new CliSettings { @@ -131,6 +141,11 @@ static CliSettings ReadSettings() DownloadAllowed = BugseeCliPolicy.DownloadAllowed( BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), }; + if (_downloadFailure != null) + { + settings.DownloadAllowed = false; + settings.DownloadDisabledNote = "Downloading bugsee-cli failed earlier in this Editor session (" + _downloadFailure + "); not retried until it restarts."; + } // Shared by every project of this user first; the project's own Library when that is not writable. string userCache = null; @@ -161,12 +176,12 @@ static CliSettings ReadSettings() static string QueryVersion(string path) { string output; - int exit = Capture(path, "--version", 15000, out output); + int exit = Capture(path, new[] { "--version" }, 15000, out output); return exit == 0 ? BugseeCliPolicy.ParseVersionOutput(output) : null; } /// Runs a process, returns its exit code (-1 when it did not start or run to the end) and stdout. - static int Capture(string file, string args, int timeoutMs, out string stdout) + static int Capture(string file, string[] args, int timeoutMs, out string stdout) { stdout = null; try @@ -174,12 +189,16 @@ static int Capture(string file, string args, int timeoutMs, out string stdout) var psi = new ProcessStartInfo { FileName = file, - Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; + foreach (string arg in args) + { + psi.ArgumentList.Add(arg); + } + using (Process proc = Process.Start(psi)) { if (proc == null) @@ -214,13 +233,25 @@ static int Capture(string file, string args, int timeoutMs, out string stdout) } } - static string Download(CliSettings settings, string cacheDir, string version) + /// Downloads, verifies and unpacks the release once into a temp directory; returns the binary there. + static string Fetch(CliSettings settings, string version) + { + try + { + return FetchUnchecked(settings, version); + } + catch (Exception ex) + { + _downloadFailure = ex.Message; + DiscardStaging(); + throw; + } + } + + static string FetchUnchecked(CliSettings settings, string version) { string triple = settings.Triple; string binaryName = BugseeCliPolicy.BinaryName(BugseeCliPolicy.IsWindowsTriple(triple)); - string finalDir = Path.GetDirectoryName(BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, triple)); - string finalBinary = Path.Combine(finalDir, binaryName); - string baseUrl = Environment.GetEnvironmentVariable("BUGSEE_CLI_BASE_URL"); string url = BugseeCliPolicy.ArchiveUrl(baseUrl, version, triple); if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) @@ -228,88 +259,132 @@ static string Download(CliSettings settings, string cacheDir, string version) throw new IOException("refusing a non-https download URL " + url); } - string work = Path.Combine(Path.GetDirectoryName(finalDir), triple + ".part-" + Guid.NewGuid().ToString("N").Substring(0, 8)); - Directory.CreateDirectory(work); - try + _stagingDir = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(_stagingDir); + Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); + string archive = Path.Combine(_stagingDir, BugseeCliPolicy.ArchiveName(triple)); + FetchFile(url, archive); + + // The pinned hash for the default release; the published sidecar for any other. + string expected = BugseeCliPolicy.KnownSha256(version, triple); + if (expected == null) { - Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); - string archive = Path.Combine(work, BugseeCliPolicy.ArchiveName(triple)); - Fetch(url, archive); - string expected = BugseeCliPolicy.ParseSha256Sidecar(FetchText(BugseeCliPolicy.ChecksumUrl(url))); + string sidecar = Path.Combine(_stagingDir, "archive.sha256"); + FetchFile(BugseeCliPolicy.ChecksumUrl(url), sidecar); + expected = BugseeCliPolicy.ParseSha256Sidecar(File.ReadAllText(sidecar)); if (expected == null) { throw new IOException("the published checksum for " + Path.GetFileName(archive) + " is malformed"); } + } - string actual = Sha256Hex(archive); - if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) - { - throw new IOException("SHA-256 mismatch for " + Path.GetFileName(archive) + ": expected " + expected + ", got " + actual); - } + string actual = Sha256Hex(archive); + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("SHA-256 mismatch for " + Path.GetFileName(archive) + ": expected " + expected + ", got " + actual); + } - string extracted = Path.Combine(work, "x"); - Directory.CreateDirectory(extracted); - Extract(archive, extracted, BugseeCliPolicy.IsWindowsTriple(triple)); - string binary = FindBinary(extracted, binaryName); - if (binary == null) - { - throw new IOException("the archive holds no " + binaryName); - } + string extracted = Path.Combine(_stagingDir, "x"); + Directory.CreateDirectory(extracted); + Extract(archive, extracted, BugseeCliPolicy.IsWindowsTriple(triple)); + string binary = FindBinary(extracted, binaryName); + if (binary == null) + { + throw new IOException("the archive holds no " + binaryName); + } - if (!BugseeCliPolicy.IsWindowsTriple(triple)) - { - int chmod = Capture("chmod", "+x \"" + binary + "\"", 15000, out _); - if (chmod != 0) - { - throw new IOException("chmod +x failed on " + binary); - } - } + if (!BugseeCliPolicy.IsWindowsTriple(triple) && Capture("chmod", new[] { "+x", binary }, 15000, out _) != 0) + { + throw new IOException("chmod +x failed on " + binary); + } + + if (QueryVersion(binary) == null) + { + throw new IOException("the downloaded " + binaryName + " does not run on this machine"); + } + + return binary; + } - // A concurrent build may have installed the same release meanwhile: keep theirs. - Directory.CreateDirectory(finalDir); - if (!File.Exists(finalBinary)) + /// + /// Puts the staged binary into the cache atomically: copied to a temp name next to the target, then moved. A + /// binary another build installed meanwhile is kept; only files created by this call are ever deleted. + /// + static string Install(CliSettings settings, string staged, string cacheDir, string version) + { + string finalBinary = BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, settings.Triple); + string finalDir = Path.GetDirectoryName(finalBinary); + Directory.CreateDirectory(finalDir); + if (File.Exists(finalBinary) && QueryVersion(finalBinary) != null) + { + return finalBinary; + } + + string temp = Path.Combine(finalDir, Path.GetFileName(finalBinary) + ".tmp-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + try + { + File.Copy(staged, temp, false); + if (!BugseeCliPolicy.IsWindowsTriple(settings.Triple) && Capture("chmod", new[] { "+x", temp }, 15000, out _) != 0) { - File.Copy(binary, finalBinary, true); + throw new IOException("chmod +x failed on " + temp); } - if (!BugseeCliPolicy.IsWindowsTriple(triple)) + try { - Capture("chmod", "+x \"" + finalBinary + "\"", 15000, out _); + File.Move(temp, finalBinary); } - - if (QueryVersion(finalBinary) == null) + catch (IOException) { - try { File.Delete(finalBinary); } catch { /* ignore */ } - throw new IOException("the downloaded " + binaryName + " does not run on this machine"); + // Another build won the race; theirs is kept if it runs. + if (!(File.Exists(finalBinary) && QueryVersion(finalBinary) != null)) + { + throw; + } } return finalBinary; } finally { - try { Directory.Delete(work, true); } catch { /* ignore */ } + try { if (File.Exists(temp)) File.Delete(temp); } catch { /* ignore */ } } } - static void Fetch(string url, string destination) + static void DiscardStaging() + { + string dir = _stagingDir; + _stagingDir = null; + if (dir != null) + { + try { Directory.Delete(dir, true); } catch { /* ignore */ } + } + } + + /// An HTTP error status: the server answered, so a second attempt with curl would get the same answer. + sealed class HttpStatusException : IOException + { + public HttpStatusException(string message) : base(message) { } + } + + static void FetchFile(string url, string destination) { try { - var request = (HttpWebRequest)WebRequest.Create(url); - request.Timeout = 30000; - request.ReadWriteTimeout = 120000; - using (var response = (HttpWebResponse)request.GetResponse()) - using (Stream input = response.GetResponseStream()) - using (var output = File.Create(destination)) - { - input.CopyTo(output); - } + FetchWithRedirects(url, destination); + } + catch (HttpStatusException) + { + throw; } catch (Exception ex) { - // Mono's TLS stack rejects some hosts' certificate chains: curl uses the system trust store. + // Mono's TLS stack rejects some hosts' certificate chains and has its own timeouts: curl uses the system + // trust store. Bounded, https only (also across redirects). string ignored; - int exit = Capture("curl", "--proto \"=https\" --tlsv1.2 -fsSL --max-time 300 -o \"" + destination + "\" \"" + url + "\"", 330000, out ignored); + int exit = Capture( + "curl", + new[] { "--proto", "=https", "--proto-redir", "=https", "--tlsv1.2", "-fsSL", "--connect-timeout", "10", "--max-time", "120", "-o", destination, url }, + 130000, out ignored); if (exit != 0 || !File.Exists(destination)) { throw new IOException("GET " + url + " failed (" + ex.Message + "; curl exit " + exit + ")"); @@ -317,18 +392,56 @@ static void Fetch(string url, string destination) } } - static string FetchText(string url) + static void FetchWithRedirects(string url, string destination) { - string temp = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8) + ".sha256"); - try + string current = url; + for (int hop = 0; hop <= 5; hop++) { - Fetch(url, temp); - return File.ReadAllText(temp); - } - finally - { - try { File.Delete(temp); } catch { /* ignore */ } + var request = (HttpWebRequest)WebRequest.Create(current); + request.Timeout = 20000; + request.ReadWriteTimeout = 60000; + request.AllowAutoRedirect = false; + HttpWebResponse response; + try + { + response = (HttpWebResponse)request.GetResponse(); + } + catch (WebException ex) when (ex.Response is HttpWebResponse errorResponse) + { + response = errorResponse; + } + + using (response) + { + int status = (int)response.StatusCode; + if (status >= 300 && status < 400) + { + var next = new Uri(new Uri(current), response.Headers["Location"] ?? ""); + if (next.Scheme != Uri.UriSchemeHttps) + { + throw new HttpStatusException("refusing a redirect to " + next + " (not https)"); + } + + current = next.AbsoluteUri; + continue; + } + + if (status < 200 || status >= 300) + { + throw new HttpStatusException("GET " + current + " returned " + status); + } + + using (Stream input = response.GetResponseStream()) + using (var output = File.Create(destination)) + { + input.CopyTo(output); + } + + return; + } } + + throw new HttpStatusException("too many redirects from " + url); } static string Sha256Hex(string path) @@ -355,7 +468,7 @@ static void Extract(string archive, string destination, bool zip) } string ignored; - int exit = Capture("tar", "-xf \"" + archive + "\" -C \"" + destination + "\"", 120000, out ignored); + int exit = Capture("tar", new[] { "-xf", archive, "-C", destination }, 120000, out ignored); if (exit != 0) { throw new IOException("tar -xf " + Path.GetFileName(archive) + " failed (exit " + exit + "); xz support is needed to unpack it"); diff --git a/Editor/BugseeCliRunner.cs b/Editor/BugseeCliRunner.cs index d73f0bc..3cb61c7 100644 --- a/Editor/BugseeCliRunner.cs +++ b/Editor/BugseeCliRunner.cs @@ -46,22 +46,20 @@ public static int Run(IList args, int timeoutMs = DefaultTimeoutMs) return -1; } - var quoted = new string[args.Count]; - for (var i = 0; i < args.Count; i++) - { - quoted[i] = Quote(args[i] ?? ""); - } - var psi = new ProcessStartInfo { FileName = CliPath, - Arguments = string.Join(" ", quoted), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; + foreach (var arg in args) + { + psi.ArgumentList.Add(arg ?? ""); + } + var token = AppToken; if (!string.IsNullOrEmpty(token)) { @@ -129,8 +127,6 @@ public static int Run(IList args, int timeoutMs = DefaultTimeoutMs) return -1; } } - - public static string Quote(string s) => $"\"{(s ?? "").Replace("\"", "\\\"")}\""; } } #endif diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index b72649e..9c2fae3 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -74,30 +74,24 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. var stem = OutputStem(report); var allowGeneric = OutputIsDirectory(report); - // The Gradle plugin (or its stand-in, GradleBuildIdBlock) writes the mapping's content id into the app, and the - // CLI derives the same id on its own. Without either the SDK reports an id built from token, version name - // and code, so the mapping is uploaded under that one or its Java frames would never be deobfuscated. - var mappingExtra = new List(); - if (!BugseeAndroidGradleSetup.BuildIdAssetInjected) - { - var buildUuid = BugseeBuildIdentity.ResolveBuildUuid(report); - mappingExtra.Add("--uuid"); - mappingExtra.Add(buildUuid); - } + // Keyed by the id the built app reports (read back from the APK or Gradle's output, see + // BugseeBuildIdentity.ResolveAndroidUploadIdentity), never assumed from what the build script contained. + var identity = BugseeBuildIdentity.ResolveAndroidUploadIdentity(report); + // Said only when a mapping is actually uploaded under it (R8 off: nothing to match). + var identityWarning = identity.Warning != null && identity.Source != BuildIdSource.None ? "Bugsee: " + identity.Warning : null; // The Gradle plugin attaches the launcher icon to the mapping (the app's icon in the dashboard). var icon = FindLauncherIcon(); if (icon != null) { Debug.Log($"Bugsee: attaching launcher icon {icon} to the mapping upload."); - mappingExtra.Add("--icon"); - mappingExtra.Add(icon); } + var mappingExtra = AndroidUploadIdentity.MappingArguments(identity.Uuid, icon); UploadIfFound(roots, "*mapping.txt", "proguard", version, build, - path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra); + path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra, identityWarning); // Unity "Create symbols.zip" / Gradle native debug symbols. - UploadSymbolsZipOrElf(report, roots, version, build); + UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid ?? Guid.NewGuid().ToString("D")); } static string FindLauncherIcon() @@ -120,7 +114,7 @@ static string FindLauncherIcon() } } - static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build) + static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build, string buildUuid) { var bestZip = FindSymbolsZip(report); var elfDirs = new List(); @@ -158,7 +152,6 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string // bugsee-cli --type elf accepts a zip of ELF objects (Unity symbols.zip layout). // --uuid is the BUILD_UUID correlation key (required by CLI); each .so is still // keyed by its own GNU build-id. - var buildUuid = BugseeBuildIdentity.ResolveBuildUuid(report); Debug.Log($"Bugsee: uploading Android symbols zip via {BugseeCliRunner.CliPath}: {bestZip}"); var args = new List { @@ -167,11 +160,8 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string "--version", version, "--build", build, }; - if (!string.IsNullOrEmpty(buildUuid)) - { - args.Add("--uuid"); - args.Add(buildUuid); - } + args.Add("--uuid"); + args.Add(buildUuid); var exit = BugseeCliRunner.Run(args); if (exit != 0) { @@ -214,7 +204,8 @@ static void UploadIfFound( string version, string build, Func rank, - IList extraArgs = null) + IList extraArgs = null, + string warnWhenFound = null) { string best = null; var bestRank = BuildArtifactChoice.Reject; @@ -233,9 +224,7 @@ static void UploadIfFound( { continue; } - // R8 writes an empty mapping when nothing was obfuscated. The Gradle plugin (and the build id - // block) write no id for it, so the app reports the SDK's own id and an upload keyed by the CLI's - // content id could never match: skip it. + // R8 writes an empty mapping when nothing was obfuscated: there is nothing to deobfuscate. if (IsEmptyFile(file)) { continue; @@ -264,6 +253,11 @@ static void UploadIfFound( return; } + if (warnWhenFound != null) + { + Debug.LogWarning(warnWhenFound); + } + Debug.Log($"Bugsee: uploading {type} via {BugseeCliRunner.CliPath}: {best}"); var args = new List { diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs new file mode 100644 index 0000000..656928b --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Bugsee.WrapperPolicy +{ + public enum BuildIdSource + { + /// BUGSEE_BUILD_UUID. + Environment, + /// assets/bugsee_build_id.properties read from the built APK/AAB: what the app reports. + AppAsset, + /// What the SDK derives itself when the app has no asset: . + Synthesized, + /// Nothing known (no token): a random id, keyed by nothing the app can report. + None, + } + + public sealed class UploadIdentity + { + public string Uuid; + public BuildIdSource Source; + /// Why the id may not match what the app reports; shown when a mapping is uploaded under it. + public string Warning; + } + + /// + /// Which build id an Android symbol upload is keyed by. The id that matters is the one the app reports at runtime, + /// so what Gradle actually produced is read back (the built APK/AAB's asset) instead of being inferred from whether + /// the Gradle block was added to the build script: that block can degrade at configuration time (a user script + /// replacing assets), and a mapping uploaded under another id never deobfuscates. The id file the block also writes + /// under launcher/build/bugsee/ is deliberately not used: Gradle reuses that directory across builds, so it can + /// belong to an earlier build. + /// + public static class AndroidUploadIdentity + { + static readonly Regex Uuid = new Regex( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + public static bool IsUuid(string text) => text != null && Uuid.IsMatch(text.Trim()); + + /// The id in a bugsee_build_id.properties file (bugsee.build_id=<uuid>), or null. + public static string ParseAssetProperties(string text) + { + if (text == null) + { + return null; + } + + foreach (string raw in text.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith("#", StringComparison.Ordinal) || !line.StartsWith("bugsee.build_id", StringComparison.Ordinal)) + { + continue; + } + + int eq = line.IndexOf('='); + if (eq > 0 && line.Substring(0, eq).Trim() == "bugsee.build_id") + { + string value = line.Substring(eq + 1).Trim(); + return IsUuid(value) ? value.ToLowerInvariant() : null; + } + } + + return null; + } + + /// + /// Priority: BUGSEE_BUILD_UUID (an explicit choice), the id the app carries, the id the SDK synthesizes. is the one the app reports, so a different BUGSEE_BUILD_UUID is + /// followed but called out. + /// + public static UploadIdentity Decide(string env, string appAssetId, string synthesized) + { + string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; + if (!string.IsNullOrWhiteSpace(env)) + { + string value = env.Trim(); + string warning = app != null && !string.Equals(app, value, StringComparison.OrdinalIgnoreCase) + ? "BUGSEE_BUILD_UUID (" + value + ") differs from the id the app reports (" + app + "): crashes will not match this upload." + : null; + return new UploadIdentity { Uuid = value, Source = BuildIdSource.Environment, Warning = warning }; + } + + if (app != null) + { + return new UploadIdentity { Uuid = app, Source = BuildIdSource.AppAsset }; + } + + if (synthesized != null) + { + return new UploadIdentity + { + Uuid = synthesized, + Source = BuildIdSource.Synthesized, + Warning = "The built app carries no bugsee_build_id.properties, so the SDK reports the id it derives from the app token, " + + "version name and version code; uploading under that id. If the app is built with a different token, version " + + "name or code than Player Settings (split APKs, versionCodeOverride), set BUGSEE_BUILD_UUID or rebuild.", + }; + } + + return new UploadIdentity { Uuid = null, Source = BuildIdSource.None }; + } + + /// Extra debug-files upload --type proguard arguments: the id (always explicit) and the launcher icon. + public static List MappingArguments(string uuid, string icon) + { + var args = new List(); + if (!string.IsNullOrEmpty(uuid)) + { + args.Add("--uuid"); + args.Add(uuid); + } + + if (!string.IsNullOrEmpty(icon)) + { + args.Add("--icon"); + args.Add(icon); + } + + return args; + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta new file mode 100644 index 0000000..07c008a --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59cd0311227b44f0843164c6944a5a8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs index 95f0844..1a46b3e 100644 --- a/Runtime/WrapperPolicy/BugseeCliPolicy.cs +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -37,6 +37,8 @@ public sealed class CliSettings /// Release triple of this host; null when none is published. public string Triple; public bool DownloadAllowed; + /// Why a download is not attempted, for the message when nothing is found. + public string DownloadDisabledNote = "Downloading bugsee-cli is disabled (BUGSEE_NO_CLI_DOWNLOAD is set)."; /// Directories a download is cached under, in order of preference. public IList CacheDirectories = new List(); } @@ -47,8 +49,12 @@ public sealed class CliProbe public Func FileExists; /// Runs path --version: the version it reports ("0.8.1"), or null when it does not run. public Func QueryVersion; - /// Downloads and verifies the release into the directory; returns the binary's path. May throw. - public Func Download; + /// Downloads, verifies and unpacks the release once into a staging location; returns the binary's path. May throw. + public Func Fetch; + /// Installs the staged binary under a cache directory (atomically, keeping a copy another build installed); returns the final path. May throw. + public Func Install; + /// Removes what staged. + public Action Discard; } public sealed class CliResolution @@ -84,6 +90,29 @@ public static class BugseeCliPolicy public const string CliName = "bugsee-cli"; + /// + /// SHA-256 of the default release's archives, pinned so the default download does not rest on the download host + /// alone (checked against download.bugsee.com's own .sha256 files when pinned). Null for any other version, + /// which is verified against the published sidecar only. + /// + public static string KnownSha256(string version, string triple) + { + if (version != DefaultVersion) + { + return null; + } + + switch (triple) + { + case "aarch64-apple-darwin": return "0ef257f16ecd5d4666590a355ab20c1d6b44d94f1c17aea4b357a21ed96a7ecf"; + case "x86_64-apple-darwin": return "35f6630cb1f05e9dd46d2cdc05741d72098f0d828fa8790412301c6d56912617"; + case "x86_64-unknown-linux-gnu": return "4d9d985945f3ec20ce56b0dca0bdf208c0d240ea9855d17ea44c6993619b220b"; + case "aarch64-unknown-linux-gnu": return "7617959bbfb14823a7cb86a1fcf876040b24ff1e5e778b3b557a44f7a375f298"; + case "x86_64-pc-windows-msvc": return "08f97390a73ec3b710eac498bb05ce9dc436482af7f6f053f73f7b70e12988a5"; + default: return null; + } + } + /// The release triple (as named in the published archives) of a host, or null when none is published. public static string HostTriple(string os, string arch) { @@ -304,15 +333,21 @@ public static CliResolution Resolve(CliSettings s, CliProbe probe) else { // A bare command name (the old default was "bugsee-cli"): look it up like any other. + var names = s.IsWindows && !explicitPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? new[] { explicitPath, explicitPath + ".exe" } + : new[] { explicitPath }; foreach (var entry in SearchDirectories(s)) { - string candidate = Combine(s, entry.Key, explicitPath); - if (probe.FileExists(candidate)) + foreach (string bare in names) { - resolution.Path = candidate; - resolution.Source = CliSource.Explicit; - resolution.Version = probe.QueryVersion(candidate); - return resolution; + string candidate = Combine(s, entry.Key, bare); + if (probe.FileExists(candidate)) + { + resolution.Path = candidate; + resolution.Source = CliSource.Explicit; + resolution.Version = probe.QueryVersion(candidate); + return resolution; + } } } @@ -368,27 +403,46 @@ public static CliResolution Resolve(CliSettings s, CliProbe probe) if (!s.DownloadAllowed) { - resolution.Notes.Add("Downloading bugsee-cli is disabled (BUGSEE_NO_CLI_DOWNLOAD is set)."); + resolution.Notes.Add(s.DownloadDisabledNote); return resolution; } - foreach (string cacheDir in s.CacheDirectories) + // One download, however many cache directories are tried: a blackholed network must cost one timeout. + string staged; + try + { + staged = probe.Fetch(s.Version); + } + catch (Exception ex) { - try + resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " failed: " + ex.Message); + return resolution; + } + + try + { + foreach (string cacheDir in s.CacheDirectories) { - string downloaded = probe.Download(cacheDir, s.Version); - if (downloaded != null && probe.FileExists(downloaded)) + try { - resolution.Path = downloaded; - resolution.Source = CliSource.Downloaded; - resolution.Version = s.Version; - return resolution; + string installed = probe.Install(staged, cacheDir, s.Version); + if (installed != null && probe.FileExists(installed)) + { + resolution.Path = installed; + resolution.Source = CliSource.Downloaded; + resolution.Version = s.Version; + return resolution; + } + } + catch (Exception ex) + { + resolution.Notes.Add("Installing bugsee-cli " + s.Version + " into " + cacheDir + " failed: " + ex.Message); } } - catch (Exception ex) - { - resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " into " + cacheDir + " failed: " + ex.Message); - } + } + finally + { + probe.Discard?.Invoke(staged); } return resolution; diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs index 43245c3..ce78d39 100644 --- a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -57,7 +57,7 @@ public static string Snippet() => string.Join("\n", new[] " @TaskAction", " void run() {", " def dst = outputDir.get().asFile", - " project.delete(dst)", + " dst.deleteDir()", " dst.mkdirs()", " def id = idFile.get().asFile.text.trim()", " if (!id.isEmpty()) {", @@ -146,6 +146,31 @@ public static string Apply(string launcherGradle, out BuildIdBlockStatus status) return MarkedGradleBlock.JoinLines(lines, newline); } + /// + /// Takes the block out again (the generated project is not always rebuilt from the template, so a block added + /// by an earlier build stays unless it is removed). Returns the text unchanged when there is none. + /// + public static string Remove(string launcherGradle) + { + if (launcherGradle == null) + { + throw new ArgumentNullException(nameof(launcherGradle)); + } + + var lines = MarkedGradleBlock.SplitLines(launcherGradle, out string newline); + int begin = lines.FindIndex(l => l.Trim() == Begin); + int end = begin < 0 ? -1 : lines.FindIndex(begin, l => l.Trim() == End); + if (begin < 0 || end < begin) + { + return launcherGradle; + } + + // The blank line Apply put after the block goes with it. + int count = end - begin + 1 + (end + 1 < lines.Count && lines[end + 1].Length == 0 ? 1 : 0); + lines.RemoveRange(begin, count); + return MarkedGradleBlock.JoinLines(lines, newline); + } + static bool SameLines(List a, List b) { if (a.Count != b.Count) diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 44c73c1..93f141a 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -92,14 +92,30 @@ public static bool AgpMeetsBugseePlugin(string rootBuildFile) } /// - /// needs the AGP 7.4 artifacts API (checked on 7.4.2) and is for versions the - /// plugin cannot run on (below 8.6): 7.4 up to 8.5. False for older AGPs (Unity 2021.3's first patch releases - /// ship AGP 4.0.1, which has no androidComponents) and when the version is unknown. + /// is for AGP 7.4.x, the only line it was run on (7.4.2, Unity 2021.3 and 2022.3). + /// Other AGPs below the plugin's 8.6 minimum are left to the SDK's own build id rather than trusted with an + /// unverified build script. False for older AGPs (Unity 2021.3's first patch releases ship AGP 4.0.1, which has + /// no androidComponents) and when the version is unknown. /// public static bool AgpSupportsBuildIdBlock(string rootBuildFile) { - return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && - ((major == 7 && minor >= 4) || (major == 8 && minor < 6)); + return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && major == 7 && minor == 4; + } + + static readonly Regex GradleVersionCode = new Regex(@"^\s*versionCode\s*=?\s*(?\d+)\s*$", RegexOptions.Multiline); + static readonly Regex GradleVersionName = new Regex(@"^\s*versionName\s*=?\s*['""](?[^'""\r\n]*)['""]\s*$", RegexOptions.Multiline); + + /// + /// The versionCode / versionName of the generated launcher build.gradle's defaultConfig: what the + /// APK carries, which can differ from Player Settings (split APK version codes, an override). Each is null when + /// the file does not state it as a plain literal. + /// + public static void ReadGradleVersion(string launcherGradle, out string versionName, out string versionCode) + { + Match code = launcherGradle == null ? Match.Empty : GradleVersionCode.Match(launcherGradle); + Match name = launcherGradle == null ? Match.Empty : GradleVersionName.Match(launcherGradle); + versionCode = code.Success ? code.Groups["v"].Value : null; + versionName = name.Success ? name.Groups["v"].Value : null; } const string PluginMarker = "bugsee:gradle-plugin"; diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs new file mode 100644 index 0000000..eb7504d --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -0,0 +1,97 @@ +using System.Linq; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidUploadIdentityTests + { + const string App = "26958224-3a30-3621-9ce7-564c211abee3"; + const string Synth = "0f5c0792-affb-3962-af95-d6e66c5828e2"; + + [Test] + public void The_id_the_app_carries_wins_over_the_synthesized_id() + { + var id = AndroidUploadIdentity.Decide(null, App, Synth); + + Assert.That(id.Uuid, Is.EqualTo(App)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); + Assert.That(id.Warning, Is.Null); + } + + [Test] + public void With_no_asset_the_sdks_own_id_is_used_and_the_warning_says_why() + { + // The block degraded (older AGP, a script replacing assets): the app reports the synthesized id, and the + // mapping must not be uploaded under a content id the app never reports. + var id = AndroidUploadIdentity.Decide(null, null, Synth); + + Assert.That(id.Uuid, Is.EqualTo(Synth)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.Synthesized)); + Assert.That(id.Warning, Does.Contain("no bugsee_build_id.properties").And.Contain("BUGSEE_BUILD_UUID")); + } + + [Test] + public void An_environment_override_wins_and_a_conflict_with_the_app_is_called_out() + { + var agrees = AndroidUploadIdentity.Decide(App.ToUpperInvariant(), App, Synth); + var conflicts = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", App, Synth); + + Assert.That(agrees.Source, Is.EqualTo(BuildIdSource.Environment)); + Assert.That(agrees.Warning, Is.Null); + Assert.That(conflicts.Uuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(conflicts.Warning, Does.Contain(App).And.Contain("will not match")); + } + + [Test] + public void Malformed_ids_are_ignored_and_nothing_known_yields_none() + { + Assert.That(AndroidUploadIdentity.Decide(null, "not-a-uuid", Synth).Source, Is.EqualTo(BuildIdSource.Synthesized)); + var none = AndroidUploadIdentity.Decide(null, null, null); + Assert.That(none.Source, Is.EqualTo(BuildIdSource.None)); + Assert.That(none.Uuid, Is.Null); + } + + [Test] + public void The_asset_file_is_parsed_like_the_sdk_reads_it() + { + string text = "# Bugsee build identifier - auto-generated, do not edit.\nbugsee.build_id=" + App.ToUpperInvariant() + "\r\n"; + + Assert.That(AndroidUploadIdentity.ParseAssetProperties(text), Is.EqualTo(App)); + Assert.That(AndroidUploadIdentity.ParseAssetProperties("bugsee.build_id=null\n"), Is.Null); + Assert.That(AndroidUploadIdentity.ParseAssetProperties("# bugsee.build_id=" + App), Is.Null); + Assert.That(AndroidUploadIdentity.ParseAssetProperties("bugsee.build_id_other=" + App), Is.Null); + Assert.That(AndroidUploadIdentity.ParseAssetProperties(null), Is.Null); + } + + [Test] + public void The_mapping_arguments_always_carry_the_id_and_the_icon_when_there_is_one() + { + Assert.That(AndroidUploadIdentity.MappingArguments(App, "/p/app_icon.png"), + Is.EqualTo(new[] { "--uuid", App, "--icon", "/p/app_icon.png" })); + Assert.That(AndroidUploadIdentity.MappingArguments(App, null), Is.EqualTo(new[] { "--uuid", App })); + Assert.That(AndroidUploadIdentity.MappingArguments(null, null), Is.Empty); + } + + [Test] + public void The_apk_version_is_read_from_the_generated_launcher() + { + string gradle = "android {\n defaultConfig {\n versionCode 2\n versionName '0.1.0'\n }\n}\n"; + + GradleTemplateDefaults.ReadGradleVersion(gradle, out var name, out var code); + + Assert.That(name, Is.EqualTo("0.1.0")); + Assert.That(code, Is.EqualTo("2")); + } + + [Test] + public void A_version_the_launcher_does_not_state_as_a_literal_stays_unknown() + { + GradleTemplateDefaults.ReadGradleVersion("versionCode project.ext.code\nversionName rootProject.name\n", out var name, out var code); + + Assert.That(name, Is.Null); + Assert.That(code, Is.Null); + GradleTemplateDefaults.ReadGradleVersion(null, out name, out code); + Assert.That(code, Is.Null); + } + } +} diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs index 489bbe7..965b78a 100644 --- a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -12,17 +12,26 @@ sealed class Host { public readonly Dictionary Files = new Dictionary(); // path -> version ("" = does not run) public readonly List Downloads = new List(); - public Func OnDownload; + public readonly List Installs = new List(); + public readonly List Discards = new List(); + public Func OnFetch; + public Func OnInstall; public CliProbe Probe() => new CliProbe { FileExists = Files.ContainsKey, QueryVersion = p => Files.TryGetValue(p, out var v) && v != "" ? v : null, - Download = (dir, version) => + Fetch = version => { - Downloads.Add(dir + "|" + version); - return OnDownload?.Invoke(dir, version); + Downloads.Add(version); + return OnFetch?.Invoke(version); }, + Install = (staged, dir, version) => + { + Installs.Add(dir); + return OnInstall?.Invoke(staged, dir, version); + }, + Discard = staged => Discards.Add(staged), }; } @@ -172,6 +181,31 @@ public void Explicit_path_that_does_not_exist_is_reported_and_discovery_continue Assert.That(r.Notes.Single(), Does.Contain("/nope/bugsee-cli").And.Contain("does not exist")); } + [Test] + public void On_windows_a_bare_command_also_tries_the_exe_name() + { + var host = new Host(); + host.Files["C:\\tools\\bugsee-cli.exe"] = "0.8.1"; + var s = new CliSettings { IsWindows = true, PathEnv = "C:\\tools", ExplicitPath = "bugsee-cli" }; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("C:\\tools\\bugsee-cli.exe")); + } + + [Test] + public void The_default_release_has_a_pinned_hash_for_every_published_host_and_other_versions_none() + { + foreach (var triple in new[] { "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc" }) + { + Assert.That(BugseeCliPolicy.KnownSha256(BugseeCliPolicy.DefaultVersion, triple), Has.Length.EqualTo(64), triple); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(BugseeCliPolicy.KnownSha256(BugseeCliPolicy.DefaultVersion, triple)), Is.Not.Null); + } + + Assert.That(BugseeCliPolicy.KnownSha256("0.8.2", "aarch64-apple-darwin"), Is.Null); + Assert.That(BugseeCliPolicy.KnownSha256(BugseeCliPolicy.DefaultVersion, "riscv"), Is.Null); + } + [Test] public void Explicit_bare_command_is_looked_up_in_every_search_directory() { @@ -215,10 +249,11 @@ public void A_cached_download_is_used_without_downloading_again() } [Test] - public void A_missing_cli_is_downloaded_into_the_first_cache_directory() + public void A_missing_cli_is_downloaded_once_and_installed_into_the_first_cache_directory() { var host = new Host(); - host.OnDownload = (dir, version) => + host.OnFetch = version => "/tmp/stage/bugsee-cli"; + host.OnInstall = (staged, dir, version) => { string path = dir + "/" + version + "/aarch64-apple-darwin/bugsee-cli"; host.Files[path] = version; @@ -229,25 +264,53 @@ public void A_missing_cli_is_downloaded_into_the_first_cache_directory() Assert.That(r.Source, Is.EqualTo(CliSource.Downloaded)); Assert.That(r.Path, Is.EqualTo("/Users/dev/Library/Caches/Bugsee/cli/0.8.1/aarch64-apple-darwin/bugsee-cli")); - Assert.That(host.Downloads, Is.EqualTo(new[] { "/Users/dev/Library/Caches/Bugsee/cli|0.8.1" })); + Assert.That(host.Downloads, Is.EqualTo(new[] { "0.8.1" })); + Assert.That(host.Installs, Is.EqualTo(new[] { "/Users/dev/Library/Caches/Bugsee/cli" })); + Assert.That(host.Discards, Is.EqualTo(new[] { "/tmp/stage/bugsee-cli" }), "the staged copy is always removed"); } [Test] - public void A_failing_download_falls_back_to_the_next_cache_directory_then_reports_every_failure() + public void A_failing_download_costs_one_attempt_whatever_the_number_of_cache_directories() { var host = new Host(); - host.OnDownload = (dir, version) => throw new InvalidOperationException("SHA-256 mismatch in " + dir); + host.OnFetch = version => throw new InvalidOperationException("timed out"); var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); Assert.That(r.Found, Is.False); - Assert.That(host.Downloads, Has.Count.EqualTo(2)); - Assert.That(r.Notes, Has.Count.EqualTo(2)); + Assert.That(host.Downloads, Has.Count.EqualTo(1)); + Assert.That(host.Installs, Is.Empty); string message = BugseeCliPolicy.NotFoundMessage(r); - Assert.That(message, Does.Contain("SHA-256 mismatch").And.Contain("BUGSEE_CLI_PATH").And.Contain("install.sh")); + Assert.That(message, Does.Contain("timed out").And.Contain("BUGSEE_CLI_PATH").And.Contain("install.sh")); Assert.That(message, Does.Contain("build continues")); } + [Test] + public void An_unwritable_first_cache_directory_falls_back_to_the_next_without_downloading_again() + { + var host = new Host(); + host.OnFetch = version => "/tmp/stage/bugsee-cli"; + host.OnInstall = (staged, dir, version) => + { + if (dir.StartsWith("/Users/dev/Library")) + { + throw new InvalidOperationException("read-only file system"); + } + + string path = dir + "/bugsee-cli"; + host.Files[path] = version; + return path; + }; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/proj/Library/Bugsee/cli/bugsee-cli")); + Assert.That(host.Downloads, Has.Count.EqualTo(1)); + Assert.That(host.Installs, Has.Count.EqualTo(2)); + Assert.That(r.Notes.Single(), Does.Contain("read-only file system")); + Assert.That(host.Discards, Has.Count.EqualTo(1)); + } + [Test] public void Download_is_not_attempted_when_it_is_not_allowed() { @@ -258,6 +321,10 @@ public void Download_is_not_attempted_when_it_is_not_allowed() Assert.That(r.Found, Is.False); Assert.That(host.Downloads, Is.Empty); Assert.That(r.Notes.Single(), Does.Contain("BUGSEE_NO_CLI_DOWNLOAD")); + + var s = Mac(download: false); + s.DownloadDisabledNote = "failed earlier in this Editor session"; + Assert.That(BugseeCliPolicy.Resolve(s, host.Probe()).Notes.Single(), Is.EqualTo("failed earlier in this Editor session")); } [Test] diff --git a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs index f527ed8..bf85e19 100644 --- a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -53,6 +53,17 @@ public void A_block_from_an_earlier_package_version_is_replaced_in_place() Assert.That(fixedUp, Is.EqualTo(once)); } + [TestCase("2021.3.45f1")] + [TestCase("2022.3.62f3")] + public void Removing_the_block_restores_the_template_exactly(string unity) + { + var launcher = Template(unity, "launcherTemplate.gradle"); + var patched = GradleBuildIdBlock.Apply(launcher, out _); + + Assert.That(GradleBuildIdBlock.Remove(patched), Is.EqualTo(launcher)); + Assert.That(GradleBuildIdBlock.Remove(launcher), Is.EqualTo(launcher), "nothing to remove"); + } + [Test] public void A_launcher_without_a_top_level_android_block_is_left_alone() { @@ -75,7 +86,9 @@ public void Crlf_launchers_keep_their_line_endings() } [Test] - public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4() + // Text checks only: no Gradle runs in these tests. That the block loads and produces the asset is what the + // real 2021.3 / 2022.3 builds in Documentation~/index.md verify. + public void The_block_text_keeps_the_properties_checked_by_the_real_builds() { var snippet = GradleBuildIdBlock.Snippet(); @@ -83,6 +96,7 @@ public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4 Assert.That(snippet, Does.Contain("catch (Throwable t)")); Assert.That(snippet, Does.Contain("variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.OBFUSCATION_MAPPING_FILE.INSTANCE)")); Assert.That(snippet, Does.Not.Contain("toListenTo"), "AGP 7.4.2 has no toListenTo"); + Assert.That(snippet, Does.Not.Contain("project.delete"), "Task.project at execution time breaks the configuration cache"); Assert.That(snippet, Does.Not.Contain("toTransform"), "transforming ASSETS on AGP 7.4.2 drops every library asset, Unity's game data included"); Assert.That(snippet, Does.Contain("variant.sources.assets.addGeneratedSourceDirectory(asset")); Assert.That(snippet, Does.Contain("bugsee_build_id.properties")); @@ -91,13 +105,16 @@ public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4 } [TestCase("id 'com.android.application' version '7.4.2' apply false", true)] + [TestCase("id 'com.android.application' version '7.4.0' apply false", true)] + [TestCase("id 'com.android.application' version '7.3.1' apply false", false)] [TestCase("id 'com.android.application' version '7.1.2' apply false", false)] - [TestCase("id 'com.android.application' version '8.3.0' apply false", true)] + [TestCase("id 'com.android.application' version '7.5.0' apply false", false)] + [TestCase("id 'com.android.application' version '8.3.0' apply false", false)] [TestCase("id 'com.android.application' version '8.6.0' apply false", false)] [TestCase("id 'com.android.application' version '9.0.0' apply false", false)] [TestCase("classpath 'com.android.tools.build:gradle:4.0.1'", false)] [TestCase("no agp here", false)] - public void The_block_is_for_agp_7_4_up_to_the_plugins_own_minimum(string root, bool expected) + public void The_block_is_only_for_agp_7_4_the_one_line_it_was_run_on(string root, bool expected) { Assert.That(GradleTemplateDefaults.AgpSupportsBuildIdBlock(root), Is.EqualTo(expected)); } diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index c8dd506..780fef1 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -5,9 +5,9 @@ namespace Bugsee.WrapperPolicy.Tests { /// - /// The Editor scripts are compiled only by Unity, so the wiring the policy classes depend on is checked in source: - /// the upload resolves its CLI through the locator, and a mapping is keyed by the SDK id when no build id asset - /// runs (Unity 2021.3 / 2022.3). + /// The Editor scripts are compiled only by Unity, so these check, in source text and nothing more, that the Editor + /// code calls the policy classes whose behaviour is tested elsewhere (BugseeCliPolicy, AndroidUploadIdentity, + /// AndroidBuildUuid). They prove wiring, not behaviour: real builds do that (see Documentation~/index.md). /// public class OldUnitySymbolUploadSourceTests { @@ -42,73 +42,50 @@ static string Body(string source, string signature) } [Test] - public void The_runner_starts_the_resolved_binary_and_never_the_bare_name() + public void The_runner_starts_the_resolved_binary_with_an_argument_list() { var source = Source("Editor/BugseeCliRunner.cs"); Assert.That(Body(source, "public static string CliPath"), Does.Contain("BugseeCliLocator.Resolve()")); Assert.That(Body(source, "public static int Run("), Does.Contain("BugseeCliLocator.ReportIfMissing()")); - Assert.That(source, Does.Not.Contain("?? \"bugsee-cli\"")); + Assert.That(Body(source, "public static int Run("), Does.Contain("psi.ArgumentList.Add(")); + Assert.That(source, Does.Not.Contain("Arguments = string.Join")); } [Test] - public void The_locator_downloads_only_over_https_and_checks_the_published_checksum() - { - var body = Body(Source("Editor/BugseeCliLocator.cs"), "static string Download("); - - Assert.That(body, Does.Contain("StartsWith(\"https://\"")); - Assert.That(body, Does.Contain("BugseeCliPolicy.ParseSha256Sidecar(")); - Assert.That(body, Does.Contain("Sha256Hex(archive)")); - Assert.That(body, Does.Contain("SHA-256 mismatch")); - } - - [Test] - public void The_locator_forgets_a_failed_lookup_when_the_next_build_starts() + public void The_download_is_verified_and_installed_through_the_policy_steps() { var source = Source("Editor/BugseeCliLocator.cs"); + var fetch = Body(source, "static string FetchUnchecked("); - Assert.That(source, Does.Contain("class BugseeCliResolutionReset : IPreprocessBuildWithReport")); + Assert.That(fetch, Does.Contain("StartsWith(\"https://\"")); + Assert.That(fetch, Does.Contain("BugseeCliPolicy.KnownSha256(")); + Assert.That(fetch, Does.Contain("SHA-256 mismatch")); + Assert.That(Body(source, "static string Install("), Does.Contain("File.Move(temp, finalBinary)")); + Assert.That(source, Does.Contain("Fetch = version => Fetch(settings, version)")); Assert.That(source, Does.Contain("public void OnPreprocessBuild(BuildReport report) => BugseeCliLocator.Reset();")); } [Test] - public void The_mapping_upload_carries_the_sdk_build_id_unless_the_gradle_plugin_runs() + public void The_symbol_upload_keys_mapping_and_native_uploads_by_the_id_read_back_from_the_build() { var body = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadAndroidSymbols("); - Assert.That(body, Does.Contain("if (!BugseeAndroidGradleSetup.BuildIdAssetInjected)")); - Assert.That(body, Does.Contain("mappingExtra.Add(\"--uuid\")")); - Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveBuildUuid(report)")); - Assert.That(body, Does.Contain("mappingExtra.Add(\"--icon\")"), "the plugin attaches the launcher icon to the mapping"); - Assert.That(Body(Source("Editor/BugseeSymbolUpload.cs"), "static string FindLauncherIcon("), Does.Contain("AndroidIconPicker.Pick(")); + Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); + Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); + Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid")); } [Test] - public void An_empty_mapping_is_never_uploaded() + public void The_identity_is_read_from_the_apk_asset_before_the_synthesized_id() { - var source = Source("Editor/BugseeSymbolUpload.cs"); - - Assert.That(Body(source, "static void UploadIfFound("), Does.Contain("if (IsEmptyFile(file))")); - Assert.That(Body(source, "static bool IsEmptyFile("), Does.Contain("new FileInfo(path).Length == 0")); - } - - [Test] - public void The_android_build_id_is_the_one_the_sdk_derives_from_the_app_as_built() - { - var body = Body(Source("Editor/BugseeBuildIdentity.cs"), "public static string ResolveBuildUuid("); + var source = Source("Editor/BugseeBuildIdentity.cs"); + var body = Body(source, "public static UploadIdentity ResolveAndroidUploadIdentity("); + Assert.That(body, Does.Contain("ReadAppAssetId(report?.summary.outputPath)")); + Assert.That(source, Does.Not.Contain("build-id.txt"), "Gradle's id file can belong to an earlier build"); Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); - Assert.That(body, Does.Contain("names.BundleVersion")); - Assert.That(body, Does.Contain("names.VersionCode")); - } - - [Test] - public void The_gradle_setup_records_whether_the_build_id_asset_was_added_for_this_build_only() - { - var source = Source("Editor/BugseeAndroidGradleSetup.cs"); - - Assert.That(Body(source, "public void OnPreprocessBuild("), Does.Contain("BuildIdAssetInjected = false")); - Assert.That(Body(source, "static void PatchGeneratedProject("), Does.Contain("BuildIdAssetInjected = pluginPresent || blockPresent")); + Assert.That(body, Does.Contain("GradleTemplateDefaults.ReadGradleVersion(")); } } } From ad4fc659090f9da39cb34bb0391ad7c5781c1417 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:14:05 +0500 Subject: [PATCH 6/9] Fix the second review round: explicit --uuid on directory ELF uploads, split-APK id read-back, block name clash and cleanup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - elf uploads of a directory carried no --uuid, which bugsee-cli 0.8.1 rejects: one argument builder for both. - Split APKs by target architecture: Unity's output is a folder of per-ABI APKs; read the id from each, warn on disagreement, and find the symbols zip named after the player without .apk. - The Gradle block clashed with com.android.build.OutputFile that the split-APK launcher imports (found by a real build): annotations are fully qualified. - Remove the block whenever it is not applied (AGP switch); version code is not trusted under versionCodeOverride; BUGSEE_BUILD_UUID must be a UUID; docs say AGP 8.0-8.5 and qualify the Unity 6 column. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 8 +- Editor/BugseeAndroidGradleSetup.cs | 18 ++-- Editor/BugseeBuildIdentity.cs | 70 +++++++++++++-- Editor/BugseeSymbolUpload.cs | 27 ++---- .../WrapperPolicy/AndroidUploadIdentity.cs | 89 ++++++++++++++++++- Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 12 +-- .../WrapperPolicy/GradleTemplateDefaults.cs | 8 +- .../AndroidUploadIdentityTests.cs | 72 +++++++++++++++ .../WrapperPolicy/GradleBuildIdBlockTests.cs | 13 +++ .../OldUnitySymbolUploadSourceTests.cs | 6 +- 11 files changed, 278 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddaa9be..491d73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (else the file the block writes under `launcher/build/bugsee/`) and uploads the mapping and the native symbols under it, with `--uuid` always explicit. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. diff --git a/Documentation~/index.md b/Documentation~/index.md index d4ca70e..f9aaf38 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -158,7 +158,7 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle 8.7+ and fails to load there (checked on 2022.3.62f3: `NoClassDefFoundError: KotlinCompilerPluginSupportPlugin`, then `NoSuchMethodError` on `OutOperationRequest.toListenTo`). What it does for a Unity app, and what stands in for it: -| Gradle plugin function | Unity 6 | Unity 2021.3 / 2022.3 | +| Gradle plugin function | Unity 6 with AGP 8.6+ (6000.0.84f1 has AGP 9.0.0; an earlier 6000.0 on AGP below 8.6 gets neither the plugin nor the block) | Unity 2021.3 / 2022.3 (AGP 7.4.x) | |---|---|---| | Declare `bugsee-android`, `bugsee-android-ndk` | Package (Maven injection) | Same | | `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`), for AGP 7.4.x only (the one line it was run on; other AGPs below 8.6 get no block). With R8 off, or no block, no asset is written and the SDK derives its own id | @@ -171,6 +171,8 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; split-APK version-code schemes and `versionNameSuffix` set outside Unity can still differ: set `BUGSEE_BUILD_UUID` then). `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding (and removes an earlier) asset block, to use the derived id only. With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. +With Split APKs by target architecture the id is read from every APK; if they disagree (or only some carry one) the build warns and the upload uses the synthesized id. A launcher that overrides the version code per output (`versionCodeOverride`, what Unity's split version-code setting adds) is not trusted for the synthesized id's version code: Player Settings' is used. + Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, uploads to UNITYTA; the generated launcher's Gradle ran AGP 7.4.2 / Gradle 7.5.1 on both): | Unity | Build | R8 | Result | @@ -180,10 +182,12 @@ Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, u | 2022.3.62f3 | development | on | builds; APK asset id equals the uploaded mapping id | | 2022.3.62f3 | release | on | same, plus emulator native, Java and managed crashes symbolicated | | 2022.3.62f3 | release | on, `BUGSEE_NO_GRADLE_BUILD_ID=1` | builds; no asset; mapping uploaded under the synthesized id with the warning; the Java crash on an emulator grouped with the symbolicated issue | +| 2022.3.62f3 | release, Split APKs by target architecture (ARMv7 + ARM64) | on | builds; Unity writes a folder `split.apk/` of per-ABI APKs: both carry the same asset id, equal to the uploaded mapping id; the symbols zip (named after `split`, not `split.apk`) is found and uploaded | +| 2022.3.62f3 | Export Project | on | builds; no native libraries exist in the export, so no ELF upload (the CLI refuses a directory upload without `--uuid`, which every ELF upload now carries; checked with `bugsee-cli --dry-run`) | | 2021.3.45f1 | release | off, development off | builds, as above | | 2021.3.45f1 | release, development | on | builds; APK asset id equals the uploaded mapping id | -Not verified: AGP 7.5 to 8.5 (no block is added there by design), Unity 2021.3 releases with AGP below 7.4, AAB output, build with Gradle configuration cache (the block avoids `Task.project` at execution time but was run without it), and Windows/Linux Editors. +Not verified: AGP 8.0 to 8.5 (no block is added there by design), Unity 2021.3 releases with AGP below 7.4, AAB output, build with Gradle configuration cache (the block avoids `Task.project` at execution time but was run without it), and Windows/Linux Editors. Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. If you do minify, add keep rules for the Unity wrapper classes: the package's `bugsee-unity-wrapper.proguard` is not applied by Unity unless you enable Player Settings > Publishing Settings > Custom Proguard File and copy the rules into `Assets/Plugins/Android/proguard-user.txt`, and that file does not yet cover `com.bugsee.unity.UnityManagedException` or `UnityWrapperIdentity` (R8 removes them: `ClassNotFoundException` at launch or on the first managed exception). diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index f2e1898..9c17c1a 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -110,27 +110,29 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } - if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") != null) - { - // Opt-out (the Unity version regenerates only what changed, so a block from an earlier build is removed). - patchedLauncher = GradleBuildIdBlock.Remove(patchedLauncher); - } - else if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") == null && + GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) { patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); - if (blockStatus != BuildIdBlockStatus.NoAnchor) + if (blockStatus == BuildIdBlockStatus.Applied) { Debug.Log( "[Bugsee] Added the build id asset task (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + ": the part of the Bugsee Gradle plugin that works on this AGP. The upload uses whatever id the built app carries."); } - else + else if (blockStatus == BuildIdBlockStatus.NoAnchor) { Debug.LogWarning( "[Bugsee] No top-level android { } block in " + launcherPath + ", so the build id asset was not added. " + "The SDK derives its own build id and the mapping is uploaded under it."); } } + else + { + // Not applying it (plugin, another AGP, opt-out): the Unity version regenerates only what changed, so a + // block an earlier build added (AGP 7.4, then a switch to 8.x) must not stay behind. + patchedLauncher = GradleBuildIdBlock.Remove(patchedLauncher); + } if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 1cb4605..0ef8a61 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using System; +using System.Collections.Generic; using System.IO; using Bugsee.WrapperPolicy; using UnityEditor; @@ -88,23 +89,76 @@ public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) code = gradleCode ?? code; } + var appId = AndroidUploadIdentity.SelectAppAssetId(ReadAppAssetIds(report?.summary.outputPath), out var conflict); + if (conflict != null) + { + Debug.LogWarning("Bugsee: " + conflict); + } + return AndroidUploadIdentity.Decide( Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"), - ReadAppAssetId(report?.summary.outputPath), - AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code)); + appId, + AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code), + conflict); } - /// The id in the APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). - static string ReadAppAssetId(string outputPath) + /// + /// The build id asset of each APK/AAB this build produced: the output file itself, or, when Unity wrote one APK + /// per ABI (Split APKs by target architecture), where that path is a folder holding them, every APK in it. + /// An entry is null when that file carries no id or cannot be read. + /// + static List ReadAppAssetIds(string outputPath) { + var ids = new List(); + if (string.IsNullOrEmpty(outputPath)) + { + return ids; + } + try { - if (string.IsNullOrEmpty(outputPath) || !File.Exists(outputPath)) + if (File.Exists(outputPath)) + { + ids.Add(ReadAppAssetId(outputPath)); + return ids; + } + + // Split APKs by target architecture: Unity turns the output path into a folder holding one APK per ABI. + if (Directory.Exists(outputPath)) + { + foreach (var apk in Directory.GetFiles(outputPath, "*.apk")) + { + ids.Add(ReadAppAssetId(apk)); + } + + return ids; + } + + var dir = Path.GetDirectoryName(Path.GetFullPath(outputPath)); + var stem = Path.GetFileNameWithoutExtension(outputPath); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir) && !string.IsNullOrEmpty(stem) && + outputPath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) { - return null; + foreach (var apk in Directory.GetFiles(dir, stem + "*.apk")) + { + ids.Add(ReadAppAssetId(apk)); + } } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: could not look for split APKs of {outputPath}: {ex.Message}"); + } + + return ids; + } - using (var zip = System.IO.Compression.ZipFile.OpenRead(outputPath)) + /// The id in one APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). + static string ReadAppAssetId(string path) + { + try + { + using (var zip = System.IO.Compression.ZipFile.OpenRead(path)) { foreach (var entry in zip.Entries) { @@ -121,7 +175,7 @@ static string ReadAppAssetId(string outputPath) } catch (Exception ex) { - Debug.LogWarning($"Bugsee: could not read the build id asset from {outputPath}: {ex.Message}"); + Debug.LogWarning($"Bugsee: could not read the build id asset from {path}: {ex.Message}"); } return null; diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 9c2fae3..4bb37b4 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -74,7 +74,7 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. var stem = OutputStem(report); var allowGeneric = OutputIsDirectory(report); - // Keyed by the id the built app reports (read back from the APK or Gradle's output, see + // Keyed by the id the built app reports (read back from the built APK/AAB, see // BugseeBuildIdentity.ResolveAndroidUploadIdentity), never assumed from what the build script contained. var identity = BugseeBuildIdentity.ResolveAndroidUploadIdentity(report); // Said only when a mapping is actually uploaded under it (R8 off: nothing to match). @@ -153,15 +153,7 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string // --uuid is the BUILD_UUID correlation key (required by CLI); each .so is still // keyed by its own GNU build-id. Debug.Log($"Bugsee: uploading Android symbols zip via {BugseeCliRunner.CliPath}: {bestZip}"); - var args = new List - { - "debug-files", "upload", bestZip, - "--type", "elf", - "--version", version, - "--build", build, - }; - args.Add("--uuid"); - args.Add(buildUuid); + var args = AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid); var exit = BugseeCliRunner.Run(args); if (exit != 0) { @@ -175,13 +167,7 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string foreach (var dir in elfDirs) { Debug.Log($"Bugsee: uploading ELF folder via {BugseeCliRunner.CliPath}: {dir}"); - var exit = BugseeCliRunner.Run(new[] - { - "debug-files", "upload", dir, - "--type", "elf", - "--version", version, - "--build", build, - }); + var exit = BugseeCliRunner.Run(AndroidUploadIdentity.ElfArguments(dir, version, build, buildUuid)); if (exit != 0) { Debug.LogWarning( @@ -345,7 +331,12 @@ internal static string OutputStem(BuildReport report) if (string.IsNullOrEmpty(path)) return null; if (OutputIsDirectory(report)) { - return Path.GetFileName(path.TrimEnd('/', '\\')); + // Split APKs by target architecture turn "Game.apk" into a folder of that name; Unity still names the + // symbols zip after "Game". + var folder = Path.GetFileName(path.TrimEnd('/', '\\')); + return folder.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || folder.EndsWith(".aab", StringComparison.OrdinalIgnoreCase) + ? Path.GetFileNameWithoutExtension(folder) + : folder; } return Path.GetFileNameWithoutExtension(path); diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs index 656928b..7d7eb6b 100644 --- a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -70,7 +70,36 @@ public static string ParseAssetProperties(string text) /// Priority: BUGSEE_BUILD_UUID (an explicit choice), the id the app carries, the id the SDK synthesizes. is the one the app reports, so a different BUGSEE_BUILD_UUID is /// followed but called out. /// - public static UploadIdentity Decide(string env, string appAssetId, string synthesized) + public static UploadIdentity Decide(string env, string appAssetId, string synthesized, string appAssetConflict = null) + { + var result = DecideCore(env, appAssetId, synthesized); + if (appAssetConflict != null) + { + result.Warning = result.Warning == null ? appAssetConflict : appAssetConflict + " " + result.Warning; + } + + return result; + } + + static UploadIdentity DecideCore(string env, string appAssetId, string synthesized) + { + string invalidEnv = null; + if (!string.IsNullOrWhiteSpace(env) && !IsUuid(env)) + { + invalidEnv = "BUGSEE_BUILD_UUID is not a UUID and is ignored."; + env = null; + } + + var result = DecideValid(env, appAssetId, synthesized); + if (invalidEnv != null) + { + result.Warning = result.Warning == null ? invalidEnv : invalidEnv + " " + result.Warning; + } + + return result; + } + + static UploadIdentity DecideValid(string env, string appAssetId, string synthesized) { string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; if (!string.IsNullOrWhiteSpace(env)) @@ -102,6 +131,64 @@ public static UploadIdentity Decide(string env, string appAssetId, string synthe return new UploadIdentity { Uuid = null, Source = BuildIdSource.None }; } + /// + /// The id of an app delivered as several APKs (Split APKs by target architecture): the one id they all carry. Null + /// when none carries one; says so when they differ or only some carry it, because + /// one upload cannot match both. + /// + public static string SelectAppAssetId(IList idsPerApk, out string conflict) + { + conflict = null; + string first = null; + bool anyMissing = false; + bool differ = false; + foreach (string raw in idsPerApk) + { + string id = IsUuid(raw) ? raw.Trim().ToLowerInvariant() : null; + if (id == null) + { + anyMissing = true; + } + else if (first == null) + { + first = id; + } + else if (first != id) + { + differ = true; + } + } + + if (first == null) + { + return null; + } + + if (differ || anyMissing) + { + conflict = "The APKs of this build do not all carry the same build id (" + + (differ ? "different ids" : "some have none") + "): crashes from some of them will not match the uploaded mapping."; + return differ ? null : first; + } + + return first; + } + + /// Arguments of an elf upload of a symbols zip or a directory: --uuid is required by the CLI for this type. + public static List ElfArguments(string path, string version, string build, string uuid) + { + var args = new List + { + "debug-files", "upload", path, + "--type", "elf", + "--version", version, + "--build", build, + }; + args.Add("--uuid"); + args.Add(uuid); + return args; + } + /// Extra debug-files upload --type proguard arguments: the id (always explicit) and the launcher icon. public static List MappingArguments(string uuid, string icon) { diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs index ce78d39..3c425d7 100644 --- a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -37,11 +37,11 @@ public static string Snippet() => string.Join("\n", new[] Begin, "// Written by the Bugsee Unity package: the Bugsee Gradle plugin cannot run on this Android Gradle plugin.", "abstract class BugseeResolveBuildIdTask extends DefaultTask {", - " @InputFile @Optional @PathSensitive(PathSensitivity.NONE)", + " @org.gradle.api.tasks.InputFile @org.gradle.api.tasks.Optional @org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.NONE)", " abstract RegularFileProperty getMappingFile()", - " @OutputFile", + " @org.gradle.api.tasks.OutputFile", " abstract RegularFileProperty getIdFile()", - " @TaskAction", + " @org.gradle.api.tasks.TaskAction", " void run() {", " def mapping = mappingFile.orNull?.asFile", " def out = idFile.get().asFile", @@ -50,11 +50,11 @@ public static string Snippet() => string.Join("\n", new[] " }", "}", "abstract class BugseeBuildIdAssetTask extends DefaultTask {", - " @InputFile @PathSensitive(PathSensitivity.NONE)", + " @org.gradle.api.tasks.InputFile @org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.NONE)", " abstract RegularFileProperty getIdFile()", - " @OutputDirectory", + " @org.gradle.api.tasks.OutputDirectory", " abstract DirectoryProperty getOutputDir()", - " @TaskAction", + " @org.gradle.api.tasks.TaskAction", " void run() {", " def dst = outputDir.get().asFile", " dst.deleteDir()", diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 93f141a..69ef034 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -108,13 +108,17 @@ public static bool AgpSupportsBuildIdBlock(string rootBuildFile) /// /// The versionCode / versionName of the generated launcher build.gradle's defaultConfig: what the /// APK carries, which can differ from Player Settings (split APK version codes, an override). Each is null when - /// the file does not state it as a plain literal. + /// the file does not state it as a plain literal. The version code is also null when the launcher overrides it per + /// output (versionCodeOverride, what Unity adds for split-APK version codes through the + /// **SPLITS_VERSION_CODE** token): the literal is then not what each APK carries. /// public static void ReadGradleVersion(string launcherGradle, out string versionName, out string versionCode) { Match code = launcherGradle == null ? Match.Empty : GradleVersionCode.Match(launcherGradle); Match name = launcherGradle == null ? Match.Empty : GradleVersionName.Match(launcherGradle); - versionCode = code.Success ? code.Groups["v"].Value : null; + versionCode = code.Success && launcherGradle.IndexOf("versionCodeOverride", StringComparison.Ordinal) < 0 + ? code.Groups["v"].Value + : null; versionName = name.Success ? name.Groups["v"].Value : null; } diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs index eb7504d..15f714b 100644 --- a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -93,5 +93,77 @@ public void A_version_the_launcher_does_not_state_as_a_literal_stays_unknown() GradleTemplateDefaults.ReadGradleVersion(null, out name, out code); Assert.That(code, Is.Null); } + + [Test] + public void Split_apks_that_all_carry_the_same_id_give_that_id() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, App.ToUpperInvariant() }, out var conflict); + + Assert.That(id, Is.EqualTo(App)); + Assert.That(conflict, Is.Null); + } + + [Test] + public void Split_apks_with_different_ids_give_none_and_a_conflict() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, "11111111-1111-1111-1111-111111111111" }, out var conflict); + + Assert.That(id, Is.Null); + Assert.That(conflict, Does.Contain("different ids")); + Assert.That(AndroidUploadIdentity.Decide(null, id, Synth, conflict).Warning, Does.Contain("different ids")); + } + + [Test] + public void Split_apks_where_only_some_carry_an_id_warn_but_use_it() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, null }, out var conflict); + + Assert.That(id, Is.EqualTo(App)); + Assert.That(conflict, Does.Contain("some have none")); + } + + [Test] + public void No_apks_or_none_with_an_id_is_not_a_conflict() + { + Assert.That(AndroidUploadIdentity.SelectAppAssetId(new string[0], out var c1), Is.Null); + Assert.That(c1, Is.Null); + Assert.That(AndroidUploadIdentity.SelectAppAssetId(new string[] { null, "" }, out var c2), Is.Null); + Assert.That(c2, Is.Null); + } + + [Test] + public void Every_elf_upload_carries_the_uuid_the_cli_requires() + { + foreach (var path in new[] { "/o/symbols.zip", "/o/Il2CppOutputProject/libs" }) + { + var args = AndroidUploadIdentity.ElfArguments(path, "0.1.0", "2", App); + + Assert.That(args, Is.EqualTo(new[] + { + "debug-files", "upload", path, "--type", "elf", "--version", "0.1.0", "--build", "2", "--uuid", App, + })); + } + } + + [Test] + public void A_build_uuid_that_is_not_a_uuid_is_ignored_with_a_warning() + { + var id = AndroidUploadIdentity.Decide("my-build-7", App, Synth); + + Assert.That(id.Uuid, Is.EqualTo(App)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); + Assert.That(id.Warning, Does.Contain("BUGSEE_BUILD_UUID is not a UUID")); + } + + [Test] + public void A_launcher_that_overrides_the_version_code_per_output_does_not_state_one() + { + string gradle = "defaultConfig {\n versionCode 2\n versionName '0.1.0'\n}\nandroid.applicationVariants.all { v -> v.outputs.each { it.versionCodeOverride = 3 } }\n"; + + GradleTemplateDefaults.ReadGradleVersion(gradle, out var name, out var code); + + Assert.That(name, Is.EqualTo("0.1.0")); + Assert.That(code, Is.Null, "the literal is not what each APK carries"); + } } } diff --git a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs index bf85e19..898bc0e 100644 --- a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -104,6 +104,19 @@ public void The_block_text_keeps_the_properties_checked_by_the_real_builds() Assert.That(snippet.Split('\n').Count(l => l.StartsWith("import ")), Is.Zero); } + [Test] + public void Annotations_are_fully_qualified_so_a_launcher_import_cannot_shadow_them() + { + // With Split APKs Unity's launcher imports com.android.build.OutputFile, which broke an unqualified @OutputFile. + foreach (var line in GradleBuildIdBlock.Snippet().Split('\n')) + { + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(line, @"@(\w+(\.\w+)*)")) + { + Assert.That(m.Groups[1].Value, Does.StartWith("org.gradle.api.tasks."), line); + } + } + } + [TestCase("id 'com.android.application' version '7.4.2' apply false", true)] [TestCase("id 'com.android.application' version '7.4.0' apply false", true)] [TestCase("id 'com.android.application' version '7.3.1' apply false", false)] diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index 780fef1..8ed463c 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -74,6 +74,10 @@ public void The_symbol_upload_keys_mapping_and_native_uploads_by_the_id_read_bac Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid")); + + var elf = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadSymbolsZipOrElf("); + Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid)")); + Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(dir, version, build, buildUuid)")); } [Test] @@ -82,7 +86,7 @@ public void The_identity_is_read_from_the_apk_asset_before_the_synthesized_id() var source = Source("Editor/BugseeBuildIdentity.cs"); var body = Body(source, "public static UploadIdentity ResolveAndroidUploadIdentity("); - Assert.That(body, Does.Contain("ReadAppAssetId(report?.summary.outputPath)")); + Assert.That(body, Does.Contain("ReadAppAssetIds(report?.summary.outputPath)")); Assert.That(source, Does.Not.Contain("build-id.txt"), "Gradle's id file can belong to an earlier build"); Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); Assert.That(body, Does.Contain("GradleTemplateDefaults.ReadGradleVersion(")); From 8c26047e24700ba36c8a8fb0e3a5ff39fc6c6c21 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:23:16 +0500 Subject: [PATCH 7/9] Do not pick one id when only some split APKs carry a build id. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/WrapperPolicy/AndroidUploadIdentity.cs | 6 +++--- Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs index 7d7eb6b..572916a 100644 --- a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -133,8 +133,8 @@ static UploadIdentity DecideValid(string env, string appAssetId, string synthesi /// /// The id of an app delivered as several APKs (Split APKs by target architecture): the one id they all carry. Null - /// when none carries one; says so when they differ or only some carry it, because - /// one upload cannot match both. + /// when none carries one, and also when they differ or only some carry it ( says + /// so): no single id matches them all, so none is picked from the ones that happen to have it. /// public static string SelectAppAssetId(IList idsPerApk, out string conflict) { @@ -168,7 +168,7 @@ public static string SelectAppAssetId(IList idsPerApk, out string confli { conflict = "The APKs of this build do not all carry the same build id (" + (differ ? "different ids" : "some have none") + "): crashes from some of them will not match the uploaded mapping."; - return differ ? null : first; + return null; } return first; diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs index 15f714b..36cbd7e 100644 --- a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -114,11 +114,11 @@ public void Split_apks_with_different_ids_give_none_and_a_conflict() } [Test] - public void Split_apks_where_only_some_carry_an_id_warn_but_use_it() + public void Split_apks_where_only_some_carry_an_id_give_none_and_a_conflict() { var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, null }, out var conflict); - Assert.That(id, Is.EqualTo(App)); + Assert.That(id, Is.Null, "the APKs without an asset report the synthesized id, so the first one's id is no better"); Assert.That(conflict, Does.Contain("some have none")); } From ae83e60bcc77a791b7af12a11e38ad6f5f3a9d3c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:35:46 +0500 Subject: [PATCH 8/9] Third review round: mapping keyed by the app's id, session-wide download failure, split-APK freshness, guards and docs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BUGSEE_BUILD_UUID no longer overrides the id the app reports for the mapping (native upload only); CHANGELOG notes it. - Download failure kept in SessionState (survives domain reloads); credentials stripped from logged URLs; a non-running cached binary is replaced; routine resolution notes are logs, not warnings. - Split APKs: ignore APKs older than the build and other players' (Game..apk only). - Android upload body and launcher read guarded: never fail the build. - Docs: pinned hashes, linemap lookup, tokenless block, Windows unverified; entries moved to Added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 6 +- Documentation~/index.md | 9 +-- Editor/BugseeBuildIdentity.cs | 35 +++++++--- Editor/BugseeCliLocator.cs | 42 +++++++---- Editor/BugseeSymbolUpload.cs | 12 +++- .../WrapperPolicy/AndroidUploadIdentity.cs | 70 ++++++++++--------- Runtime/WrapperPolicy/BugseeCliPolicy.cs | 12 ++++ Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 5 +- .../AndroidUploadIdentityTests.cs | 58 ++++++++++----- Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 12 ++++ .../OldUnitySymbolUploadSourceTests.cs | 4 +- 11 files changed, 181 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491d73c..e8421b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. @@ -31,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. +- Breaking for CI setups: `BUGSEE_BUILD_UUID` keeps keying the native (ELF) upload as before, but the mapping upload is now keyed by the id the built app carries and `BUGSEE_BUILD_UUID` only applies to it when the app has no such asset (and is a UUID). A CI that set a random GUID per build no longer moves the mapping off the id the app reports. + - EXPERIMENTAL, Android only, off by default: `Bugsee.Capture.BugseeFrameCapturer.Enable()` pushes Unity's own screen to the SDK through `VideoMode.DirectBuffers` and `VideoFrameConsumer.addFrame` (`ScreenCapture.CaptureScreenshotIntoRenderTexture` + `AsyncGPUReadback`, one native copy into a reused buffer, masked in place and wrapped with `AndroidJNI.NewDirectByteBuffer`: no Java array; the wrapper writes no frame to disk, the SDK still writes its recorded video), one request in flight with frames dropped meanwhile, throttled to `TargetFps` (default 5). **Supported on release builds of Unity 2022.2.0f1 and newer (2022.3 LTS, 6000.x); not on 2021.3 or 2022.1** (`AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11; alphas and betas are treated as unsupported): there `Enable()` returns false with one warning and the SDK's own capture stays in effect. A launch that asks for `VideoMode.DirectBuffers` starts the capturer itself, and whenever it cannot run (old Unity, no Screen Capture module, no AsyncGPUReadback, a graphics API other than OpenGLES3 or Vulkan) the wrapper replaces `DirectBuffers` with the default capture (`VideoMode.V2`) before the native SDK launches, with one warning, and if the capturer cannot start after the launch was accepted (or stops itself later) it relaunches with the default capture, unless a newer Launch/Relaunch is waiting; it never launches `DirectBuffers` without a frame source. It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (registered at capture start or at readback); pixelation and secure views are not applied; blackout is honoured by the SDK. The readback row order was measured for Vulkan (Galaxy Tab S5e) and OpenGLES3 (emulator), both on Unity 6000.0.84f1. On any other graphics API the row order is unverified: a launch falls back to the default capture, and a capturer started directly with `Enable()` fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. Measured once on a Galaxy Tab S5e (Vulkan, light sample scene): it is not lighter than the SDK's default capture (same frame rate and about the same CPU, about 15 MB more PSS). Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). - `Bugsee.GetLaunchOptions()` (Android and iOS): the effective launch options (SDK defaults merged with the host's) as a read-only dictionary, or `null` while the SDK is not launched. Keys are the native SDKs' own option names. - `Bugsee.Notify(title, body, severity, fields, urgent)` (Android and iOS): relays a lightweight notification to the app's messaging integrations without creating a report. An empty title is ignored with a warning. diff --git a/Documentation~/index.md b/Documentation~/index.md index f9aaf38..9321edd 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -128,11 +128,11 @@ IL2CPP **C# file/line** via LNM always needs uploaded `il2cpp-linemap` + matchin Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`, `BUGSEE_BUILD_UUID`. -### Where `bugsee-cli` comes from (Android Editor uploads) +### Where `bugsee-cli` comes from (Editor uploads) -A Unity Editor started from the Hub or the Finder does not inherit your shell's `PATH`, so a CLI installed with Homebrew or `install.sh` is often invisible to it. The Editor uploads therefore look, in order, at: `BUGSEE_CLI_PATH`; the Editor's `PATH`; the places the installers use (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`); the package's cache (`~/Library/Caches/Bugsee/cli` on macOS, `$XDG_CACHE_HOME/bugsee/cli` or `~/.cache/bugsee/cli` on Linux, `%LOCALAPPDATA%\Bugsee\cache\cli` on Windows, else `Library/Bugsee/cli` in the project). An installed CLI older than 0.8.0 is passed over. +This lookup serves every Editor upload on any platform that uses the CLI from the Editor (the Android mapping and symbols, and the IL2CPP linemap upload of a standalone or Android build). A Unity Editor started from the Hub or the Finder does not inherit your shell's `PATH`, so a CLI installed with Homebrew or `install.sh` is often invisible to it. The Editor uploads therefore look, in order, at: `BUGSEE_CLI_PATH`; the Editor's `PATH`; the places the installers use (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`); the package's cache (`~/Library/Caches/Bugsee/cli` on macOS, `$XDG_CACHE_HOME/bugsee/cli` or `~/.cache/bugsee/cli` on Linux, `%LOCALAPPDATA%\Bugsee\cache\cli` on Windows, else `Library/Bugsee/cli` in the project). An installed CLI older than 0.8.0 is passed over. -When none is found and a token is set, the pinned release (0.8.1, the Bugsee Gradle plugin's default) is downloaded from `https://download.bugsee.com/cli/v/bugsee-cli-.tar.xz` (`.zip` on Windows), checked against the published `.sha256`, unpacked into that cache and run once to prove it works. The checksum comes from the same host as the archive, so it detects corruption, not a compromised host: set `BUGSEE_CLI_PATH` to a binary you vetted if that matters. `BUGSEE_NO_CLI_DOWNLOAD=1` turns the download off, `BUGSEE_CLI_VERSION` pins another release and `BUGSEE_CLI_BASE_URL` points at a mirror (https only), as for `install.sh`. If nothing can be found or downloaded the build warns once, naming what was tried, and continues without uploading. The iOS Run Script still starts a bare `bugsee-cli` (or `BUGSEE_CLI_PATH`) and does not use this lookup. +When none is found and a token is set, the pinned release (0.8.1, the Bugsee Gradle plugin's default) is downloaded from `https://download.bugsee.com/cli/v/bugsee-cli-.tar.xz` (`.zip` on Windows), checked against the SHA-256 pinned in the package for that release (a release you pin with `BUGSEE_CLI_VERSION` is checked against the published `.sha256` only), unpacked into that cache and run once to prove it works. For a release pinned in the package the checksum does not depend on the download host; for any other it comes from the same host as the archive, so it detects corruption, not a compromised host: set `BUGSEE_CLI_PATH` to a binary you vetted if that matters. Credentials in `BUGSEE_CLI_BASE_URL` are not logged. Not verified on Windows (the zip and `.exe` paths are covered by unit tests only). `BUGSEE_NO_CLI_DOWNLOAD=1` turns the download off, `BUGSEE_CLI_VERSION` pins another release and `BUGSEE_CLI_BASE_URL` points at a mirror (https only), as for `install.sh`. If nothing can be found or downloaded the build warns once, naming what was tried, and continues without uploading. The iOS Run Script still starts a bare `bugsee-cli` (or `BUGSEE_CLI_PATH`) and does not use this lookup. The iOS Run Script uploads for **every** Xcode configuration (Debug included, so a crash from a development build is symbolicated) and runs on every build that has the token in its environment, not only archives. Set `BUGSEE_SKIP_UPLOAD_ON_DEBUG=1` to skip the Debug configuration, for example for local Xcode runs. @@ -169,7 +169,7 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle | Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | | Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | -The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; split-APK version-code schemes and `versionNameSuffix` set outside Unity can still differ: set `BUGSEE_BUILD_UUID` then). `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding (and removes an earlier) asset block, to use the derived id only. With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. +The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; a `versionNameSuffix` or version code changed outside Unity's generated launcher can still differ, and then a mapping uploaded under the derived id will not match; only the asset avoids that). `BUGSEE_BUILD_UUID` keys the native upload as before; for the mapping it is used only when the app carries no asset and the value is a UUID, and never overrides the app's own id. The asset block is added on AGP 7.4.x whenever the build is Android, token or not (like the Unity 6 plugin, which is applied regardless); `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding it (and removes an earlier one) and leaves only the derived id. Its Groovy classes are compiled by Gradle for every build, and the part that runs per variant is guarded so a failure there only costs the id; a tokenless 2022.3 release build with R8 on was run to check they do not break Gradle (see the matrix). With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. With Split APKs by target architecture the id is read from every APK; if they disagree (or only some carry one) the build warns and the upload uses the synthesized id. A launcher that overrides the version code per output (`versionCodeOverride`, what Unity's split version-code setting adds) is not trusted for the synthesized id's version code: Player Settings' is used. @@ -183,6 +183,7 @@ Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, u | 2022.3.62f3 | release | on | same, plus emulator native, Java and managed crashes symbolicated | | 2022.3.62f3 | release | on, `BUGSEE_NO_GRADLE_BUILD_ID=1` | builds; no asset; mapping uploaded under the synthesized id with the warning; the Java crash on an emulator grouped with the symbolicated issue | | 2022.3.62f3 | release, Split APKs by target architecture (ARMv7 + ARM64) | on | builds; Unity writes a folder `split.apk/` of per-ABI APKs: both carry the same asset id, equal to the uploaded mapping id; the symbols zip (named after `split`, not `split.apk`) is found and uploaded | +| 2022.3.62f3 | release, no app token | on | builds; the block is added regardless and the asset is in the APK; no upload, no warning | | 2022.3.62f3 | Export Project | on | builds; no native libraries exist in the export, so no ELF upload (the CLI refuses a directory upload without `--uuid`, which every ELF upload now carries; checked with `bugsee-cli --dry-run`) | | 2021.3.45f1 | release | off, development off | builds, as above | | 2021.3.45f1 | release, development | on | builds; APK asset id equals the uploaded mapping id | diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 0ef8a61..2d6b9e0 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -82,14 +82,21 @@ public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) // What the APK carries, when the generated launcher says so plainly (split APK codes, overrides). var root = BugseeAndroidGradleSetup.GeneratedProjectRoot; var launcher = string.IsNullOrEmpty(root) ? null : Path.Combine(root, "launcher", "build.gradle"); - if (launcher != null && File.Exists(launcher)) + try + { + if (launcher != null && File.Exists(launcher)) + { + GradleTemplateDefaults.ReadGradleVersion(File.ReadAllText(launcher), out var gradleName, out var gradleCode); + name = gradleName ?? name; + code = gradleCode ?? code; + } + } + catch (Exception ex) { - GradleTemplateDefaults.ReadGradleVersion(File.ReadAllText(launcher), out var gradleName, out var gradleCode); - name = gradleName ?? name; - code = gradleCode ?? code; + Debug.LogWarning($"Bugsee: could not read the generated launcher's version: {ex.Message}"); } - var appId = AndroidUploadIdentity.SelectAppAssetId(ReadAppAssetIds(report?.summary.outputPath), out var conflict); + var appId = AndroidUploadIdentity.SelectAppAssetId(ReadAppAssetIds(report?.summary.outputPath, report == null ? DateTime.MinValue : report.summary.buildStartedAt.ToUniversalTime()), out var conflict); if (conflict != null) { Debug.LogWarning("Bugsee: " + conflict); @@ -107,7 +114,7 @@ public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) /// per ABI (Split APKs by target architecture), where that path is a folder holding them, every APK in it. /// An entry is null when that file carries no id or cannot be read. /// - static List ReadAppAssetIds(string outputPath) + static List ReadAppAssetIds(string outputPath, DateTime buildStartedUtc) { var ids = new List(); if (string.IsNullOrEmpty(outputPath)) @@ -128,7 +135,10 @@ static List ReadAppAssetIds(string outputPath) { foreach (var apk in Directory.GetFiles(outputPath, "*.apk")) { - ids.Add(ReadAppAssetId(apk)); + if (IsFromThisBuild(apk, buildStartedUtc)) + { + ids.Add(ReadAppAssetId(apk)); + } } return ids; @@ -139,9 +149,13 @@ static List ReadAppAssetIds(string outputPath) if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir) && !string.IsNullOrEmpty(stem) && outputPath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) { - foreach (var apk in Directory.GetFiles(dir, stem + "*.apk")) + foreach (var apk in Directory.GetFiles(dir, "*.apk")) { - ids.Add(ReadAppAssetId(apk)); + // "Game.arm64-v8a.apk" yes; "GameLite.apk" (another player) and APKs of earlier builds no. + if (AndroidUploadIdentity.IsSplitApkOf(Path.GetFileName(apk), stem) && IsFromThisBuild(apk, buildStartedUtc)) + { + ids.Add(ReadAppAssetId(apk)); + } } } } @@ -153,6 +167,9 @@ static List ReadAppAssetIds(string outputPath) return ids; } + static bool IsFromThisBuild(string file, DateTime buildStartedUtc) => + File.GetLastWriteTimeUtc(file) >= buildStartedUtc; + /// The id in one APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). static string ReadAppAssetId(string path) { diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs index cbf1c26..b5dc07b 100644 --- a/Editor/BugseeCliLocator.cs +++ b/Editor/BugseeCliLocator.cs @@ -27,8 +27,14 @@ internal static class BugseeCliLocator { static CliResolution _resolved; static bool _notFoundReported; - // Kept for the editor session (Reset does not clear it): a blackholed network must cost one timeout, not one per build. - static string _downloadFailure; + // Kept for the editor session, across domain reloads (SessionState; Reset does not clear it): a blackholed network + // must cost one timeout, not one per build. + const string DownloadFailureKey = "Bugsee.CliDownloadFailure"; + static string DownloadFailure + { + get { var value = UnityEditor.SessionState.GetString(DownloadFailureKey, ""); return value.Length == 0 ? null : value; } + set { UnityEditor.SessionState.SetString(DownloadFailureKey, value ?? ""); } + } static string _stagingDir; public static CliResolution Resolve() @@ -59,9 +65,10 @@ public static CliResolution Resolve() if (_resolved.Found) { Debug.Log("[Bugsee] Using bugsee-cli " + (_resolved.Version ?? "(version unknown)") + " (" + Describe(_resolved.Source) + "): " + _resolved.Path); + // Routine when something was found after all (a shim that does not run, an old install passed over). foreach (string note in _resolved.Notes) { - Debug.LogWarning("[Bugsee] " + note); + Debug.Log("[Bugsee] " + note); } } @@ -141,10 +148,10 @@ static CliSettings ReadSettings() DownloadAllowed = BugseeCliPolicy.DownloadAllowed( BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), }; - if (_downloadFailure != null) + if (DownloadFailure != null) { settings.DownloadAllowed = false; - settings.DownloadDisabledNote = "Downloading bugsee-cli failed earlier in this Editor session (" + _downloadFailure + "); not retried until it restarts."; + settings.DownloadDisabledNote = "Downloading bugsee-cli failed earlier in this Editor session (" + DownloadFailure + "); not retried until it restarts."; } // Shared by every project of this user first; the project's own Library when that is not writable. @@ -242,7 +249,7 @@ static string Fetch(CliSettings settings, string version) } catch (Exception ex) { - _downloadFailure = ex.Message; + DownloadFailure = ex.Message; DiscardStaging(); throw; } @@ -256,12 +263,12 @@ static string FetchUnchecked(CliSettings settings, string version) string url = BugseeCliPolicy.ArchiveUrl(baseUrl, version, triple); if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - throw new IOException("refusing a non-https download URL " + url); + throw new IOException("refusing a non-https download URL " + BugseeCliPolicy.RedactUrl(url)); } _stagingDir = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8)); Directory.CreateDirectory(_stagingDir); - Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); + Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + BugseeCliPolicy.RedactUrl(url)); string archive = Path.Combine(_stagingDir, BugseeCliPolicy.ArchiveName(triple)); FetchFile(url, archive); @@ -315,9 +322,16 @@ static string Install(CliSettings settings, string staged, string cacheDir, stri string finalBinary = BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, settings.Triple); string finalDir = Path.GetDirectoryName(finalBinary); Directory.CreateDirectory(finalDir); - if (File.Exists(finalBinary) && QueryVersion(finalBinary) != null) + if (File.Exists(finalBinary)) { - return finalBinary; + if (QueryVersion(finalBinary) != null) + { + return finalBinary; + } + + // A leftover that does not run (an interrupted install, another machine's architecture) would make the + // move below fail for ever: replace it. + try { File.Delete(finalBinary); } catch { /* the move reports it */ } } string temp = Path.Combine(finalDir, Path.GetFileName(finalBinary) + ".tmp-" + Guid.NewGuid().ToString("N").Substring(0, 8)); @@ -387,7 +401,7 @@ static void FetchFile(string url, string destination) 130000, out ignored); if (exit != 0 || !File.Exists(destination)) { - throw new IOException("GET " + url + " failed (" + ex.Message + "; curl exit " + exit + ")"); + throw new IOException("GET " + BugseeCliPolicy.RedactUrl(url) + " failed (" + ex.Message + "; curl exit " + exit + ")"); } } } @@ -419,7 +433,7 @@ static void FetchWithRedirects(string url, string destination) var next = new Uri(new Uri(current), response.Headers["Location"] ?? ""); if (next.Scheme != Uri.UriSchemeHttps) { - throw new HttpStatusException("refusing a redirect to " + next + " (not https)"); + throw new HttpStatusException("refusing a redirect to " + BugseeCliPolicy.RedactUrl(next.AbsoluteUri) + " (not https)"); } current = next.AbsoluteUri; @@ -428,7 +442,7 @@ static void FetchWithRedirects(string url, string destination) if (status < 200 || status >= 300) { - throw new HttpStatusException("GET " + current + " returned " + status); + throw new HttpStatusException("GET " + BugseeCliPolicy.RedactUrl(current) + " returned " + status); } using (Stream input = response.GetResponseStream()) @@ -441,7 +455,7 @@ static void FetchWithRedirects(string url, string destination) } } - throw new HttpStatusException("too many redirects from " + url); + throw new HttpStatusException("too many redirects from " + BugseeCliPolicy.RedactUrl(url)); } static string Sha256Hex(string path) diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 4bb37b4..f5d9f8f 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -40,7 +40,15 @@ public void OnPostprocessBuild(BuildReport report) switch (report.summary.platform) { case BuildTarget.Android: - UploadAndroidSymbols(report, version, build); + try + { + UploadAndroidSymbols(report, version, build); + } + catch (Exception ex) + { + // Whatever goes wrong here (an unreadable file, a locked folder) must not fail the build. + Debug.LogWarning($"Bugsee: Android symbol upload failed: {ex.Message} — build continues."); + } break; case BuildTarget.iOS: case BuildTarget.tvOS: @@ -91,7 +99,7 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil UploadIfFound(roots, "*mapping.txt", "proguard", version, build, path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra, identityWarning); // Unity "Create symbols.zip" / Gradle native debug symbols. - UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid ?? Guid.NewGuid().ToString("D")); + UploadSymbolsZipOrElf(report, roots, version, build, identity.ElfUuid ?? Guid.NewGuid().ToString("D")); } static string FindLauncherIcon() diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs index 572916a..9b97ec1 100644 --- a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -18,7 +18,10 @@ public enum BuildIdSource public sealed class UploadIdentity { + /// The id the mapping is keyed by. public string Uuid; + /// The id the native (ELF) upload is keyed by: BUGSEE_BUILD_UUID when set, else . + public string ElfUuid; public BuildIdSource Source; /// Why the id may not match what the app reports; shown when a mapping is uploaded under it. public string Warning; @@ -67,12 +70,17 @@ public static string ParseAssetProperties(string text) } /// - /// Priority: BUGSEE_BUILD_UUID (an explicit choice), the id the app carries, the id the SDK synthesizes. is the one the app reports, so a different BUGSEE_BUILD_UUID is - /// followed but called out. + /// The mapping is keyed by the id the app reports: the id the built app carries, else (no asset) a valid + /// BUGSEE_BUILD_UUID the user set as the id they know the app reports, else the id the SDK synthesizes. An asset + /// always wins over the environment: a value that differs from what the app reports could never match. + /// BUGSEE_BUILD_UUID keeps its older meaning for the native upload (): any + /// non-empty value is that upload's correlation key; without it the mapping's id is used. /// public static UploadIdentity Decide(string env, string appAssetId, string synthesized, string appAssetConflict = null) { - var result = DecideCore(env, appAssetId, synthesized); + var result = DecideMapping(env, appAssetId, synthesized); + string envValue = string.IsNullOrWhiteSpace(env) ? null : env.Trim(); + result.ElfUuid = envValue ?? result.Uuid; if (appAssetConflict != null) { result.Warning = result.Warning == null ? appAssetConflict : appAssetConflict + " " + result.Warning; @@ -81,39 +89,27 @@ public static UploadIdentity Decide(string env, string appAssetId, string synthe return result; } - static UploadIdentity DecideCore(string env, string appAssetId, string synthesized) - { - string invalidEnv = null; - if (!string.IsNullOrWhiteSpace(env) && !IsUuid(env)) - { - invalidEnv = "BUGSEE_BUILD_UUID is not a UUID and is ignored."; - env = null; - } - - var result = DecideValid(env, appAssetId, synthesized); - if (invalidEnv != null) - { - result.Warning = result.Warning == null ? invalidEnv : invalidEnv + " " + result.Warning; - } - - return result; - } - - static UploadIdentity DecideValid(string env, string appAssetId, string synthesized) + static UploadIdentity DecideMapping(string env, string appAssetId, string synthesized) { string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; - if (!string.IsNullOrWhiteSpace(env)) + string envValue = string.IsNullOrWhiteSpace(env) ? null : env.Trim(); + if (app != null) { - string value = env.Trim(); - string warning = app != null && !string.Equals(app, value, StringComparison.OrdinalIgnoreCase) - ? "BUGSEE_BUILD_UUID (" + value + ") differs from the id the app reports (" + app + "): crashes will not match this upload." + string note = envValue != null && !string.Equals(app, envValue, StringComparison.OrdinalIgnoreCase) + ? "BUGSEE_BUILD_UUID (" + envValue + ") is not the id the app reports (" + app + "); the mapping is uploaded under the app's id and BUGSEE_BUILD_UUID is used for the native upload only." : null; - return new UploadIdentity { Uuid = value, Source = BuildIdSource.Environment, Warning = warning }; + return new UploadIdentity { Uuid = app, Source = BuildIdSource.AppAsset, Warning = note }; } - if (app != null) + string envNote = null; + if (envValue != null) { - return new UploadIdentity { Uuid = app, Source = BuildIdSource.AppAsset }; + if (IsUuid(envValue)) + { + return new UploadIdentity { Uuid = envValue.ToLowerInvariant(), Source = BuildIdSource.Environment }; + } + + envNote = "BUGSEE_BUILD_UUID is not a UUID, so it is not used as the mapping's id (it still keys the native upload)."; } if (synthesized != null) @@ -122,13 +118,23 @@ static UploadIdentity DecideValid(string env, string appAssetId, string synthesi { Uuid = synthesized, Source = BuildIdSource.Synthesized, - Warning = "The built app carries no bugsee_build_id.properties, so the SDK reports the id it derives from the app token, " + + Warning = (envNote == null ? "" : envNote + " ") + + "The built app carries no bugsee_build_id.properties, so the SDK reports the id it derives from the app token, " + "version name and version code; uploading under that id. If the app is built with a different token, version " + - "name or code than Player Settings (split APKs, versionCodeOverride), set BUGSEE_BUILD_UUID or rebuild.", + "name or code than Player Settings, the mapping will not match it.", }; } - return new UploadIdentity { Uuid = null, Source = BuildIdSource.None }; + return new UploadIdentity { Uuid = null, Source = BuildIdSource.None, Warning = envNote }; + } + + /// Whether an APK file name is a sibling of the player "Game.apk": "Game.arm64-v8a.apk", not "GameLite.apk". + public static bool IsSplitApkOf(string fileName, string stem) + { + return !string.IsNullOrEmpty(stem) && fileName != null && + fileName.StartsWith(stem + ".", StringComparison.OrdinalIgnoreCase) && + fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) && + fileName.Length > stem.Length + ".apk".Length + 1; } /// diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs index 1a46b3e..6103291 100644 --- a/Runtime/WrapperPolicy/BugseeCliPolicy.cs +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -139,6 +139,18 @@ public static bool IsValidVersion(string version) => version != null && System.Text.RegularExpressions.Regex.IsMatch( version, @"^[0-9]+(\.[0-9]+)*([-+][0-9A-Za-z.]+)?$"); + /// The URL without credentials (https://user:pass@host/...), for logs and messages. + public static string RedactUrl(string url) + { + Uri uri; + if (url == null || !Uri.TryCreate(url, UriKind.Absolute, out uri) || string.IsNullOrEmpty(uri.UserInfo)) + { + return url; + } + + return new UriBuilder(uri) { UserName = "", Password = "" }.Uri.AbsoluteUri; + } + public static bool IsWindowsTriple(string triple) => triple != null && triple.Contains("windows"); public static string BinaryName(bool windows) => windows ? CliName + ".exe" : CliName; diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs index 3c425d7..ec9aab1 100644 --- a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -17,8 +17,9 @@ public enum BuildIdBlockStatus /// The part of the Bugsee Gradle plugin that gives the app its build id, for Android Gradle plugins too old to run /// the plugin (Unity 2021.3 / 2022.3 pin AGP 7.4.2; the plugin needs 8.6+). After R8 it writes /// assets/bugsee_build_id.properties holding the UUID of the R8 mapping (MD5 name-based, what the plugin - /// and bugsee-cli --type proguard compute), which is what the SDK reports at runtime. The mapping upload - /// then needs no --uuid: the CLI derives the same id from the file. With no mapping (R8 off) no asset is + /// and bugsee-cli --type proguard compute), which is what the SDK reports at runtime. The upload + /// reads the id back from the built APK and passes it as --uuid (it equals the id the CLI derives from the + /// mapping file). With no mapping (R8 off) no asset is /// written and the SDK derives its own id (). /// /// Uses only API present in AGP 7.4 (artifacts.get(OBFUSCATION_MAPPING_FILE) and a generated assets source diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs index 36cbd7e..9de31a7 100644 --- a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -27,19 +27,53 @@ public void With_no_asset_the_sdks_own_id_is_used_and_the_warning_says_why() Assert.That(id.Uuid, Is.EqualTo(Synth)); Assert.That(id.Source, Is.EqualTo(BuildIdSource.Synthesized)); - Assert.That(id.Warning, Does.Contain("no bugsee_build_id.properties").And.Contain("BUGSEE_BUILD_UUID")); + Assert.That(id.Warning, Does.Contain("no bugsee_build_id.properties").And.Contain("will not match")); } [Test] - public void An_environment_override_wins_and_a_conflict_with_the_app_is_called_out() + public void The_apps_id_keys_the_mapping_and_the_environment_value_keys_only_the_native_upload() { - var agrees = AndroidUploadIdentity.Decide(App.ToUpperInvariant(), App, Synth); - var conflicts = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", App, Synth); + // A CI that sets a fresh GUID per build must not move the mapping off the id the app reports. + var id = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", App, Synth); + + Assert.That(id.Uuid, Is.EqualTo(App)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); + Assert.That(id.ElfUuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(id.Warning, Does.Contain("native upload only")); + } - Assert.That(agrees.Source, Is.EqualTo(BuildIdSource.Environment)); + [Test] + public void The_native_upload_follows_the_mapping_when_the_environment_is_unset_or_agrees() + { + Assert.That(AndroidUploadIdentity.Decide(null, App, Synth).ElfUuid, Is.EqualTo(App)); + var agrees = AndroidUploadIdentity.Decide(App.ToUpperInvariant(), App, Synth); Assert.That(agrees.Warning, Is.Null); - Assert.That(conflicts.Uuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); - Assert.That(conflicts.Warning, Does.Contain(App).And.Contain("will not match")); + Assert.That(agrees.Uuid, Is.EqualTo(App)); + } + + [Test] + public void Without_an_asset_a_valid_environment_uuid_is_the_mapping_id_and_any_value_keys_the_native_upload() + { + var uuid = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", null, Synth); + var free = AndroidUploadIdentity.Decide("my-build-7", null, Synth); + + Assert.That(uuid.Uuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(uuid.Source, Is.EqualTo(BuildIdSource.Environment)); + Assert.That(free.Uuid, Is.EqualTo(Synth), "a non-UUID cannot be the mapping's id"); + Assert.That(free.ElfUuid, Is.EqualTo("my-build-7"), "it still keys the native upload, as before"); + Assert.That(free.Warning, Does.Contain("not a UUID")); + } + + [TestCase("Game.arm64-v8a.apk", "Game", true)] + [TestCase("game.ARMEABI-V7A.APK", "Game", true)] + [TestCase("GameLite.apk", "Game", false)] + [TestCase("Game.apk", "Game", false)] + [TestCase("Game..apk", "Game", false)] + [TestCase("Game.arm64-v8a.aab", "Game", false)] + [TestCase("Game.arm64-v8a.apk", "", false)] + public void Only_a_players_own_split_apks_are_siblings(string file, string stem, bool expected) + { + Assert.That(AndroidUploadIdentity.IsSplitApkOf(file, stem), Is.EqualTo(expected)); } [Test] @@ -145,16 +179,6 @@ public void Every_elf_upload_carries_the_uuid_the_cli_requires() } } - [Test] - public void A_build_uuid_that_is_not_a_uuid_is_ignored_with_a_warning() - { - var id = AndroidUploadIdentity.Decide("my-build-7", App, Synth); - - Assert.That(id.Uuid, Is.EqualTo(App)); - Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); - Assert.That(id.Warning, Does.Contain("BUGSEE_BUILD_UUID is not a UUID")); - } - [Test] public void A_launcher_that_overrides_the_version_code_per_output_does_not_state_one() { diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs index 965b78a..0b86615 100644 --- a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -362,6 +362,18 @@ public void Only_release_versions_may_reach_a_path_or_url(string version, bool e Assert.That(BugseeCliPolicy.IsValidVersion(version), Is.EqualTo(expected)); } + [TestCase("https://user:secret@mirror.example/cli", "https://mirror.example/cli")] + [TestCase("https://mirror.example/cli/v0.8.1/x.tar.xz", "https://mirror.example/cli/v0.8.1/x.tar.xz")] + [TestCase("not a url", "not a url")] + [TestCase(null, null)] + public void Credentials_are_removed_from_urls_before_they_are_logged(string url, string expected) + { + var redacted = BugseeCliPolicy.RedactUrl(url); + + Assert.That(redacted == null ? null : redacted.TrimEnd('/'), Is.EqualTo(expected == null ? null : expected.TrimEnd('/'))); + Assert.That(redacted ?? "", Does.Not.Contain("secret")); + } + [Test] public void Cache_layout_is_version_then_triple() { diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index 8ed463c..ef20399 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -73,7 +73,7 @@ public void The_symbol_upload_keys_mapping_and_native_uploads_by_the_id_read_bac Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); - Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid")); + Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.ElfUuid")); var elf = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadSymbolsZipOrElf("); Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid)")); @@ -86,7 +86,7 @@ public void The_identity_is_read_from_the_apk_asset_before_the_synthesized_id() var source = Source("Editor/BugseeBuildIdentity.cs"); var body = Body(source, "public static UploadIdentity ResolveAndroidUploadIdentity("); - Assert.That(body, Does.Contain("ReadAppAssetIds(report?.summary.outputPath)")); + Assert.That(body, Does.Contain("ReadAppAssetIds(report?.summary.outputPath,")); Assert.That(source, Does.Not.Contain("build-id.txt"), "Gradle's id file can belong to an earlier build"); Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); Assert.That(body, Does.Contain("GradleTemplateDefaults.ReadGradleVersion(")); From a7b46a1538e443846a01774daf0dc4a71869ba2a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:42:35 +0500 Subject: [PATCH 9/9] CHANGELOG/docs: state the mapping and native upload ids separately. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8421b0..3cd7833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping under it, and the native symbols under it too unless `BUGSEE_BUILD_UUID` is set (see the next entry), with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the mapping is uploaded under that, with a warning, unless `BUGSEE_BUILD_UUID` holds a UUID, which is then used for the mapping instead. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Breaking for CI setups: `BUGSEE_BUILD_UUID` keeps keying the native (ELF) upload as before, but the mapping upload is now keyed by the id the built app carries and `BUGSEE_BUILD_UUID` only applies to it when the app has no such asset (and is a UUID). A CI that set a random GUID per build no longer moves the mapping off the id the app reports. - EXPERIMENTAL, Android only, off by default: `Bugsee.Capture.BugseeFrameCapturer.Enable()` pushes Unity's own screen to the SDK through `VideoMode.DirectBuffers` and `VideoFrameConsumer.addFrame` (`ScreenCapture.CaptureScreenshotIntoRenderTexture` + `AsyncGPUReadback`, one native copy into a reused buffer, masked in place and wrapped with `AndroidJNI.NewDirectByteBuffer`: no Java array; the wrapper writes no frame to disk, the SDK still writes its recorded video), one request in flight with frames dropped meanwhile, throttled to `TargetFps` (default 5). **Supported on release builds of Unity 2022.2.0f1 and newer (2022.3 LTS, 6000.x); not on 2021.3 or 2022.1** (`AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11; alphas and betas are treated as unsupported): there `Enable()` returns false with one warning and the SDK's own capture stays in effect. A launch that asks for `VideoMode.DirectBuffers` starts the capturer itself, and whenever it cannot run (old Unity, no Screen Capture module, no AsyncGPUReadback, a graphics API other than OpenGLES3 or Vulkan) the wrapper replaces `DirectBuffers` with the default capture (`VideoMode.V2`) before the native SDK launches, with one warning, and if the capturer cannot start after the launch was accepted (or stops itself later) it relaunches with the default capture, unless a newer Launch/Relaunch is waiting; it never launches `DirectBuffers` without a frame source. It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (registered at capture start or at readback); pixelation and secure views are not applied; blackout is honoured by the SDK. The readback row order was measured for Vulkan (Galaxy Tab S5e) and OpenGLES3 (emulator), both on Unity 6000.0.84f1. On any other graphics API the row order is unverified: a launch falls back to the default capture, and a capturer started directly with `Enable()` fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. Measured once on a Galaxy Tab S5e (Vulkan, light sample scene): it is not lighter than the SDK's default capture (same frame rate and about the same CPU, about 15 MB more PSS). Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). diff --git a/Documentation~/index.md b/Documentation~/index.md index 9321edd..9099939 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -169,7 +169,7 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle | Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | | Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | -The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; a `versionNameSuffix` or version code changed outside Unity's generated launcher can still differ, and then a mapping uploaded under the derived id will not match; only the asset avoids that). `BUGSEE_BUILD_UUID` keys the native upload as before; for the mapping it is used only when the app carries no asset and the value is a UUID, and never overrides the app's own id. The asset block is added on AGP 7.4.x whenever the build is Android, token or not (like the Unity 6 plugin, which is applied regardless); `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding it (and removes an earlier one) and leaves only the derived id. Its Groovy classes are compiled by Gradle for every build, and the part that runs per variant is guarded so a failure there only costs the id; a tokenless 2022.3 release build with R8 on was run to check they do not break Gradle (see the matrix). With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. +The mapping upload of an Android build is keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else (when `BUGSEE_BUILD_UUID` is not a UUID) the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; a `versionNameSuffix` or version code changed outside Unity's generated launcher can still differ, and then a mapping uploaded under the derived id will not match; only the asset avoids that). The native upload uses the same id unless `BUGSEE_BUILD_UUID` is set, which keys it as before (any non-empty value); for the mapping `BUGSEE_BUILD_UUID` is used only when the app carries no asset and the value is a UUID, and never overrides the app's own id. The asset block is added on AGP 7.4.x whenever the build is Android, token or not (like the Unity 6 plugin, which is applied regardless); `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding it (and removes an earlier one) and leaves only the derived id. Its Groovy classes are compiled by Gradle for every build, and the part that runs per variant is guarded so a failure there only costs the id; a tokenless 2022.3 release build with R8 on was run to check they do not break Gradle (see the matrix). With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. With Split APKs by target architecture the id is read from every APK; if they disagree (or only some carry one) the build warns and the upload uses the synthesized id. A launcher that overrides the version code per output (`versionCodeOverride`, what Unity's split version-code setting adds) is not trusted for the synthesized id's version code: Player Settings' is used.