diff --git a/CHANGELOG.md b/CHANGELOG.md index 9551ee0..3cd7833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,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 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). - `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 d991fa9..9099939 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 (Editor uploads) + +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 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. @@ -146,7 +152,45 @@ 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 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 | +| `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 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. + +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 | +| 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 | + +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). **iOS** diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 5040551..9c17c1a 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -28,14 +28,19 @@ 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"; + /// 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) { + GeneratedProjectRoot = null; RemoveLegacyTemplates(); } } @@ -61,7 +66,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 +110,30 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } + if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") == null && + GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + { + patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); + 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 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)) { File.WriteAllText(rootPath, patchedRoot); diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index c77aa45..2d6b9e0 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; @@ -67,19 +68,134 @@ 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. + /// 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"); + 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) + { + Debug.LogWarning($"Bugsee: could not read the generated launcher's version: {ex.Message}"); + } + + 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); + } + + return AndroidUploadIdentity.Decide( + Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"), + appId, + AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code), + conflict); + } + + /// + /// 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, DateTime buildStartedUtc) + { + var ids = new List(); + if (string.IsNullOrEmpty(outputPath)) + { + return ids; + } + + try + { + 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")) + { + if (IsFromThisBuild(apk, buildStartedUtc)) + { + 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)) + { + foreach (var apk in Directory.GetFiles(dir, "*.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)); + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: could not look for split APKs of {outputPath}: {ex.Message}"); + } + + 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) + { + try + { + using (var zip = System.IO.Compression.ZipFile.OpenRead(path)) + { + 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) { - return env.Trim(); + Debug.LogWarning($"Bugsee: could not read the build id asset from {path}: {ex.Message}"); } - // Prefer a stable dashed UUID when BUGSEE_BUILD_UUID is unset. - return Guid.NewGuid().ToString("D"); + return null; } /// diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs new file mode 100644 index 0000000..b5dc07b --- /dev/null +++ b/Editor/BugseeCliLocator.cs @@ -0,0 +1,511 @@ +#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; + // 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() + { + 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, + Fetch = version => Fetch(settings, version), + Install = (staged, cacheDir, version) => Install(settings, staged, cacheDir, version), + Discard = _ => DiscardStaging(), + }); + } + 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); + // 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.Log("[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"); + 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; + } + 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 + { + ExplicitPath = Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH"), + PathEnv = Environment.GetEnvironmentVariable("PATH"), + Home = home, + LocalAppData = localAppData, + IsWindows = IsWindows, + Version = version ?? BugseeCliPolicy.DefaultVersion, + Triple = BugseeCliPolicy.HostTriple(HostOs, RuntimeInformation.OSArchitecture.ToString()), + 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; + 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, 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) + { + stdout = null; + try + { + var psi = new ProcessStartInfo + { + FileName = file, + 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) + { + 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; + } + } + + /// 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 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 " + 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 " + BugseeCliPolicy.RedactUrl(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) + { + 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 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) && 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; + } + + /// + /// 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)) + { + 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)); + try + { + File.Copy(staged, temp, false); + if (!BugseeCliPolicy.IsWindowsTriple(settings.Triple) && Capture("chmod", new[] { "+x", temp }, 15000, out _) != 0) + { + throw new IOException("chmod +x failed on " + temp); + } + + try + { + File.Move(temp, finalBinary); + } + catch (IOException) + { + // Another build won the race; theirs is kept if it runs. + if (!(File.Exists(finalBinary) && QueryVersion(finalBinary) != null)) + { + throw; + } + } + + return finalBinary; + } + finally + { + try { if (File.Exists(temp)) File.Delete(temp); } catch { /* ignore */ } + } + } + + 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 + { + FetchWithRedirects(url, destination); + } + catch (HttpStatusException) + { + throw; + } + catch (Exception ex) + { + // 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", + 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 " + BugseeCliPolicy.RedactUrl(url) + " failed (" + ex.Message + "; curl exit " + exit + ")"); + } + } + } + + static void FetchWithRedirects(string url, string destination) + { + string current = url; + for (int hop = 0; hop <= 5; hop++) + { + 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 " + BugseeCliPolicy.RedactUrl(next.AbsoluteUri) + " (not https)"); + } + + current = next.AbsoluteUri; + continue; + } + + if (status < 200 || status >= 300) + { + throw new HttpStatusException("GET " + BugseeCliPolicy.RedactUrl(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 " + BugseeCliPolicy.RedactUrl(url)); + } + + 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", 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"); + } + } + + 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..3cb61c7 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,22 +41,25 @@ 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++) + if (BugseeCliLocator.ReportIfMissing()) { - quoted[i] = Quote(args[i] ?? ""); + return -1; } 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)) { @@ -114,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 84831fd..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: @@ -74,13 +82,47 @@ 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 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). + 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."); + } + + var mappingExtra = AndroidUploadIdentity.MappingArguments(identity.Uuid, icon); UploadIfFound(roots, "*mapping.txt", "proguard", version, build, - path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric)); + 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.ElfUuid ?? Guid.NewGuid().ToString("D")); } - static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string 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, string buildUuid) { var bestZip = FindSymbolsZip(report); var elfDirs = new List(); @@ -118,20 +160,8 @@ 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 - { - "debug-files", "upload", bestZip, - "--type", "elf", - "--version", version, - "--build", build, - }; - if (!string.IsNullOrEmpty(buildUuid)) - { - args.Add("--uuid"); - args.Add(buildUuid); - } + var args = AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid); var exit = BugseeCliRunner.Run(args); if (exit != 0) { @@ -145,13 +175,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( @@ -173,7 +197,9 @@ static void UploadIfFound( string type, string version, string build, - Func rank) + Func rank, + IList extraArgs = null, + string warnWhenFound = null) { string best = null; var bestRank = BuildArtifactChoice.Reject; @@ -192,6 +218,11 @@ static void UploadIfFound( { continue; } + // R8 writes an empty mapping when nothing was obfuscated: there is nothing to deobfuscate. + if (IsEmptyFile(file)) + { + continue; + } var fileRank = rank(file); if (fileRank == BuildArtifactChoice.Reject) { @@ -216,14 +247,25 @@ static void UploadIfFound( return; } + if (warnWhenFound != null) + { + Debug.LogWarning(warnWhenFound); + } + 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."); @@ -297,7 +339,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); @@ -338,6 +385,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/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/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs new file mode 100644 index 0000000..9b97ec1 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -0,0 +1,217 @@ +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 + { + /// 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; + } + + /// + /// 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; + } + + /// + /// 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 = 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; + } + + return result; + } + + static UploadIdentity DecideMapping(string env, string appAssetId, string synthesized) + { + string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; + string envValue = string.IsNullOrWhiteSpace(env) ? null : env.Trim(); + if (app != null) + { + 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 = app, Source = BuildIdSource.AppAsset, Warning = note }; + } + + string envNote = null; + if (envValue != null) + { + 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) + { + return new UploadIdentity + { + Uuid = synthesized, + Source = BuildIdSource.Synthesized, + 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, the mapping will not match it.", + }; + } + + 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; + } + + /// + /// 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, 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) + { + 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 null; + } + + 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) + { + 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 new file mode 100644 index 0000000..6103291 --- /dev/null +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -0,0 +1,477 @@ +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; + /// 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(); + } + + /// 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, 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 + { + 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"; + + /// + /// 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) + { + 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; + } + + /// + /// 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.]+)?$"); + + /// 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; + + /// 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. + var names = s.IsWindows && !explicitPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? new[] { explicitPath, explicitPath + ".exe" } + : new[] { explicitPath }; + foreach (var entry in SearchDirectories(s)) + { + foreach (string bare in names) + { + 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; + } + } + } + + 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(s.DownloadDisabledNote); + return resolution; + } + + // 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) + { + resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " failed: " + ex.Message); + return resolution; + } + + try + { + foreach (string cacheDir in s.CacheDirectories) + { + try + { + 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); + } + } + } + finally + { + probe.Discard?.Invoke(staged); + } + + 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/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs new file mode 100644 index 0000000..ec9aab1 --- /dev/null +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -0,0 +1,193 @@ +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 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 + /// 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 {", + " @org.gradle.api.tasks.InputFile @org.gradle.api.tasks.Optional @org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.NONE)", + " abstract RegularFileProperty getMappingFile()", + " @org.gradle.api.tasks.OutputFile", + " abstract RegularFileProperty getIdFile()", + " @org.gradle.api.tasks.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 {", + " @org.gradle.api.tasks.InputFile @org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.NONE)", + " abstract RegularFileProperty getIdFile()", + " @org.gradle.api.tasks.OutputDirectory", + " abstract DirectoryProperty getOutputDir()", + " @org.gradle.api.tasks.TaskAction", + " void run() {", + " def dst = outputDir.get().asFile", + " dst.deleteDir()", + " 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); + } + + 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); + } + + /// + /// 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) + { + 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..69ef034 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -91,6 +91,37 @@ public static bool AgpMeetsBugseePlugin(string rootBuildFile) (major > 8 || (major == 8 && minor >= 6)); } + /// + /// 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; + } + + 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. 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 && launcherGradle.IndexOf("versionCodeOverride", StringComparison.Ordinal) < 0 + ? code.Groups["v"].Value + : null; + versionName = name.Success ? name.Groups["v"].Value : null; + } + 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 7c85a15..49ae3a4 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) + internal static string JoinLines(List lines, string newline) { var sb = new StringBuilder(); for (int i = 0; i < lines.Count; i++) @@ -639,7 +639,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(); @@ -669,7 +669,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/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs new file mode 100644 index 0000000..9de31a7 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -0,0 +1,193 @@ +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("will not match")); + } + + [Test] + public void The_apps_id_keys_the_mapping_and_the_environment_value_keys_only_the_native_upload() + { + // 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")); + } + + [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(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] + 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); + } + + [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_give_none_and_a_conflict() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, null }, out var conflict); + + 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")); + } + + [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_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/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs new file mode 100644 index 0000000..0b86615 --- /dev/null +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -0,0 +1,385 @@ +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 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, + Fetch = 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), + }; + } + + 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 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() + { + 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_once_and_installed_into_the_first_cache_directory() + { + var host = new Host(); + 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; + 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[] { "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_costs_one_attempt_whatever_the_number_of_cache_directories() + { + var host = new Host(); + 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(1)); + Assert.That(host.Installs, Is.Empty); + string message = BugseeCliPolicy.NotFoundMessage(r); + 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() + { + 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")); + + 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] + 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)); + } + + [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)); + } + + [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() + { + 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")); + } + } +} 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..898bc0e --- /dev/null +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -0,0 +1,135 @@ +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)); + } + + [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() + { + 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] + // 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(); + + 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("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")); + Assert.That(snippet, Does.Contain("bugsee.build_id=${id}")); + 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)] + [TestCase("id 'com.android.application' version '7.1.2' apply false", false)] + [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_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 new file mode 100644 index 0000000..ef20399 --- /dev/null +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// 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 + { + 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_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(Body(source, "public static int Run("), Does.Contain("psi.ArgumentList.Add(")); + Assert.That(source, Does.Not.Contain("Arguments = string.Join")); + } + + [Test] + 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(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_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("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); + Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); + 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)")); + Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(dir, version, build, buildUuid)")); + } + + [Test] + 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(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(")); + } + } +}