From 4ff8677eca79b336c915ec4eb4e7eaac9f73705b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:36:08 +0500 Subject: [PATCH 001/157] Ignore local git worktrees used for parallel wrapper tasks. Co-authored-by: Cursor --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ee3be34..22ed22a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ Samples~/**/[Bb]uilds/ # Native binaries produced by Tools~/scripts/update-native-sdks.sh Native~/ios/Bugsee/Bugsee.xcframework/ +# Local git worktrees for parallel task branches +.worktrees/ + # Packaging *.unitypackage *.apk From 7629fd79857184a6d1b7581a74d9b4c30a424eb8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:39:46 +0500 Subject: [PATCH 002/157] Map breadcrumb levels by name so Android and iOS do not share inverted integers. Co-authored-by: Cursor --- Runtime/WrapperPolicy/BreadcrumbLevelMap.cs | 52 +++++++++++++++++++ .../WrapperPolicy/BreadcrumbLevelMapTests.cs | 30 +++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 19 +++++++ 3 files changed, 101 insertions(+) create mode 100644 Runtime/WrapperPolicy/BreadcrumbLevelMap.cs create mode 100644 Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj diff --git a/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs new file mode 100644 index 0000000..a55ca36 --- /dev/null +++ b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs @@ -0,0 +1,52 @@ +namespace Bugsee.WrapperPolicy +{ + public enum BreadcrumbLevelName + { + Debug, + Info, + Warning, + Error, + Fatal + } + + public static class BreadcrumbLevelMap + { + public static bool TryParse(string name, out BreadcrumbLevelName level) + { + switch (name) + { + case "debug": level = BreadcrumbLevelName.Debug; return true; + case "info": level = BreadcrumbLevelName.Info; return true; + case "warning": level = BreadcrumbLevelName.Warning; return true; + case "error": level = BreadcrumbLevelName.Error; return true; + case "fatal": level = BreadcrumbLevelName.Fatal; return true; + default: + level = default; + return false; + } + } + + public static int ToAndroid(BreadcrumbLevelName level) + { + switch (level) + { + case BreadcrumbLevelName.Debug: return 1; + case BreadcrumbLevelName.Info: return 2; + case BreadcrumbLevelName.Warning: return 3; + case BreadcrumbLevelName.Error: return 4; + default: return 5; + } + } + + public static int ToIos(BreadcrumbLevelName level) + { + switch (level) + { + case BreadcrumbLevelName.Debug: return 4; + case BreadcrumbLevelName.Info: return 3; + case BreadcrumbLevelName.Warning: return 2; + default: return 1; + } + } + } +} diff --git a/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs b/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs new file mode 100644 index 0000000..45996d6 --- /dev/null +++ b/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs @@ -0,0 +1,30 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class BreadcrumbLevelMapTests + { + [Test] + public void Debug_is_1_on_android_and_4_on_ios() + { + Assert.That(BreadcrumbLevelMap.TryParse("debug", out var level), Is.True); + Assert.That(BreadcrumbLevelMap.ToAndroid(level), Is.EqualTo(1)); + Assert.That(BreadcrumbLevelMap.ToIos(level), Is.EqualTo(4)); + } + + [Test] + public void Fatal_is_5_on_android_and_error_on_ios() + { + Assert.That(BreadcrumbLevelMap.TryParse("fatal", out var level), Is.True); + Assert.That(BreadcrumbLevelMap.ToAndroid(level), Is.EqualTo(5)); + Assert.That(BreadcrumbLevelMap.ToIos(level), Is.EqualTo(1)); + } + + [Test] + public void Unknown_name_is_rejected() + { + Assert.That(BreadcrumbLevelMap.TryParse("verbose", out _), Is.False); + Assert.That(BreadcrumbLevelMap.TryParse("Debug", out _), Is.False); + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + From 0fb41af48af55a981ecaa86e6ca061a185199344 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:39:52 +0500 Subject: [PATCH 003/157] Record host logs as Custom unless the wrapper names an allowed source. Co-authored-by: Cursor --- .../WrapperPolicy/WrapperLogSourcePolicy.cs | 27 ++++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 19 +++++++++++ .../WrapperLogSourcePolicyTests.cs | 32 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj create mode 100644 Tests~/WrapperPolicy/WrapperLogSourcePolicyTests.cs diff --git a/Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs b/Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs new file mode 100644 index 0000000..69cda5b --- /dev/null +++ b/Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs @@ -0,0 +1,27 @@ +namespace Bugsee.WrapperPolicy +{ + public static class WrapperLogSourcePolicy + { + public const int Unknown = 0; + public const int StdOut = 1; + public const int StdErr = 2; + public const int WebView = 5; + public const int Custom = 98; + + public static int Resolve(int? source) + { + if (!source.HasValue) return Custom; + switch (source.Value) + { + case Unknown: + case StdOut: + case StdErr: + case WebView: + case Custom: + return source.Value; + default: + return Custom; + } + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + diff --git a/Tests~/WrapperPolicy/WrapperLogSourcePolicyTests.cs b/Tests~/WrapperPolicy/WrapperLogSourcePolicyTests.cs new file mode 100644 index 0000000..68be3c2 --- /dev/null +++ b/Tests~/WrapperPolicy/WrapperLogSourcePolicyTests.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class WrapperLogSourcePolicyTests + { + [Test] + public void Missing_source_is_Custom() + { + Assert.That(WrapperLogSourcePolicy.Resolve(null), Is.EqualTo(98)); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(5)] + [TestCase(98)] + public void Allow_list_is_unchanged(int source) + { + Assert.That(WrapperLogSourcePolicy.Resolve(source), Is.EqualTo(source)); + } + + [TestCase(3)] + [TestCase(4)] + [TestCase(99)] + [TestCase(-1)] + public void Anything_else_is_Custom(int source) + { + Assert.That(WrapperLogSourcePolicy.Resolve(source), Is.EqualTo(98)); + } + } +} From 37f601099ec901b637515d50053689bf60930821 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:40:13 +0500 Subject: [PATCH 004/157] Register the iOS wrapper at load so crash recovery sees it before launch. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 71 ++++++++++++------- Plugins/iOS/BugseeUnityCallbacks.mm | 49 ++++++++++--- .../Bugsee.WrapperPolicy.Tests.csproj | 19 +++++ .../IosRegistrationSourceTests.cs | 46 ++++++++++++ 4 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj create mode 100644 Tests~/WrapperPolicy/IosRegistrationSourceTests.cs diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 5c0d483..866b7e5 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -33,35 +33,53 @@ static id BugseeDeserializeJson(const char *string) return c ? [NSString stringWithUTF8String:c] : nil; } +static void BugseeRunOnMain(dispatch_block_t block) +{ + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + extern "C" { void _bugsee_launch(const char *appToken, const char *optionsJson) { - NSDictionary *dict = nil; - if (optionsJson) { - id parsed = BugseeDeserializeJson(optionsJson); - if ([parsed isKindOfClass:[NSDictionary class]]) { - dict = parsed; + NSString *token = BugseeNSString(appToken); + NSString *optionsStr = optionsJson ? [NSString stringWithUTF8String:optionsJson] : nil; + BugseeRunOnMain(^{ + NSDictionary *dict = nil; + if (optionsStr) { + id parsed = BugseeDeserializeJson(optionsStr.UTF8String); + if ([parsed isKindOfClass:[NSDictionary class]]) { + dict = parsed; + } } - } - [Bugsee launchWithToken:BugseeNSString(appToken) andOptions:dict]; + [Bugsee launchWithToken:token andOptions:dict]; + }); } void _bugsee_relaunch(const char *optionsJson) { - NSDictionary *dict = nil; - if (optionsJson) { - id parsed = BugseeDeserializeJson(optionsJson); - if ([parsed isKindOfClass:[NSDictionary class]]) { - dict = parsed; + NSString *optionsStr = optionsJson ? [NSString stringWithUTF8String:optionsJson] : nil; + BugseeRunOnMain(^{ + NSDictionary *dict = nil; + if (optionsStr) { + id parsed = BugseeDeserializeJson(optionsStr.UTF8String); + if ([parsed isKindOfClass:[NSDictionary class]]) { + dict = parsed; + } } - } - [Bugsee relaunchWithDictionaryOptions:dict]; + [Bugsee relaunchWithDictionaryOptions:dict]; + }); } void _bugsee_stop(void) { - [Bugsee stop:nil]; + BugseeRunOnMain(^{ + [Bugsee stop:nil]; + }); } bool _bugsee_get_launched(void) @@ -155,15 +173,20 @@ void _bugsee_test_crash(void) void _bugsee_show_report(const char *summary, const char *description, int severity, const char *labelsJson) { - NSArray *labels = BugseeLabelsFromJson(labelsJson); - if (summary) { - [Bugsee showReportDialogWithSummary:BugseeNSString(summary) - description:BugseeNSString(description) ?: @"" - severity:(BugseeSeverityLevel)severity - labels:labels]; - } else { - [Bugsee showReportDialog]; - } + NSString *summaryStr = summary ? [NSString stringWithUTF8String:summary] : nil; + NSString *descriptionStr = description ? [NSString stringWithUTF8String:description] : nil; + NSString *labelsStr = labelsJson ? [NSString stringWithUTF8String:labelsJson] : nil; + BugseeRunOnMain(^{ + NSArray *labels = BugseeLabelsFromJson(labelsStr.UTF8String); + if (summaryStr) { + [Bugsee showReportDialogWithSummary:summaryStr + description:descriptionStr ?: @"" + severity:(BugseeSeverityLevel)severity + labels:labels]; + } else { + [Bugsee showReportDialog]; + } + }); } void _bugsee_upload(const char *summary, const char *description, int severity, const char *labelsJson) diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 87a3b55..c855005 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -36,8 +36,12 @@ static NSMutableDictionary *gPending; static int64_t gNextRequestId = 1; static NSObject *gLock; -static NSString *gWrapperVersion = @"0.1.0"; -static NSString *gWrapperBuild = @"dev"; +static NSString *gWrapperVersion = nil; +static NSString *gWrapperBuild = nil; +static NSDictionary *gWrapperContext = nil; + +@class BugseeUnityWrapper; +static BugseeUnityWrapper *gWrapper; static void BugseeUnityEnsureState(void) { @@ -281,10 +285,18 @@ @interface BugseeUnityWrapper : NSObject @implementation BugseeUnityWrapper ++ (void)load +{ + if (!gWrapper) { + gWrapper = [BugseeUnityWrapper new]; + } + [Bugsee setWrapper:gWrapper]; +} + - (NSString *)wrapperType { return @"unity"; } -- (NSString *)wrapperVersion { return gWrapperVersion ?: @"0.1.0"; } -- (NSString *)wrapperBuild { return gWrapperBuild ?: @"dev"; } -- (NSDictionary *)context { return @{}; } +- (NSString *)wrapperVersion { return gWrapperVersion ?: @"unknown"; } +- (NSString *)wrapperBuild { return gWrapperBuild ?: @"unknown"; } +- (NSDictionary *)context { return gWrapperContext ?: @{}; } - (void)requestDataWithType:(NSString *)dataType callback:(id)callback { @@ -350,8 +362,6 @@ - (void)forwardReport:(id)report @end -static BugseeUnityWrapper *gWrapper; - static int64_t BugseeUnityEnqueueFilter(NSString *kind, id event, id decisionBlock) { BugseeUnityEnsureState(); @@ -383,9 +393,29 @@ void _bugsee_ensure_wrapper(const char *version, const char *build) if (version) gWrapperVersion = [NSString stringWithUTF8String:version]; if (build) gWrapperBuild = [NSString stringWithUTF8String:build]; if (!gWrapper) { - gWrapper = [BugseeUnityWrapper new]; + NSLog(@"[Bugsee] ensure-wrapper"); + return; } - [Bugsee setWrapper:gWrapper]; +} + +void _bugsee_set_wrapper_context(const char *json) +{ + if (!json) { + gWrapperContext = nil; + return; + } + NSDictionary *parsed = BugseeUnityParseJson(json); + if (![parsed isKindOfClass:[NSDictionary class]]) { + gWrapperContext = @{}; + return; + } + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + [(NSDictionary *)parsed enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if ([key isKindOfClass:[NSString class]] && [obj isKindOfClass:[NSString class]]) { + out[(NSString *)key] = (NSString *)obj; + } + }]; + gWrapperContext = [out copy]; } void _bugsee_set_network_filter_enabled(int enabled) @@ -505,6 +535,7 @@ void _bugsee_complete_report(int64_t requestId, const char *resultJson) extern "C" { void _bugsee_register_unity_callbacks(void *a, void *b, void *c) { (void)a; (void)b; (void)c; } void _bugsee_ensure_wrapper(const char *version, const char *build) { (void)version; (void)build; } +void _bugsee_set_wrapper_context(const char *json) { (void)json; } void _bugsee_set_network_filter_enabled(int enabled) { (void)enabled; } void _bugsee_set_log_filter_enabled(int enabled) { (void)enabled; } void _bugsee_set_breadcrumb_filter_enabled(int enabled) { (void)enabled; } diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + diff --git a/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs b/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs new file mode 100644 index 0000000..abd9ac4 --- /dev/null +++ b/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs @@ -0,0 +1,46 @@ +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IosRegistrationSourceTests + { + static string FindRepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + { + return dir; + } + + var parent = Directory.GetParent(dir)?.FullName; + if (string.IsNullOrEmpty(parent) || parent == dir) + { + break; + } + + dir = parent; + } + + Assert.Fail("Could not find repo root (package.json)"); + return null; + } + + [Test] + public void Wrapper_is_created_in_load_and_ensure_does_not_allocate() + { + string root = FindRepoRoot(); + string path = Path.Combine(root, "Plugins", "iOS", "BugseeUnityCallbacks.mm"); + string text = File.ReadAllText(path); + Assert.That(text, Does.Contain("+ (void)load")); + Assert.That(text, Does.Contain("[Bugsee setWrapper:")); + int ensure = text.IndexOf("void _bugsee_ensure_wrapper"); + int next = text.IndexOf("void _bugsee_", ensure + 10); + string body = text.Substring(ensure, next - ensure); + Assert.That(body, Does.Not.Contain("BugseeUnityWrapper new")); + Assert.That(body, Does.Not.Contain("[Bugsee setWrapper:")); + } + } +} From 4bcecb7fc9d85286699e70c0cf9b24f58d8bd58c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:40:18 +0500 Subject: [PATCH 005/157] Treat iOS severity 0 as unset instead of VeryLow. Co-authored-by: Cursor --- Runtime/Contracts/Options/IssueSeverity.cs | 18 +++++------ Runtime/WrapperPolicy/IssueSeverityWire.cs | 32 +++++++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 19 +++++++++++ .../WrapperPolicy/IssueSeverityWireTests.cs | 23 +++++++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 Runtime/WrapperPolicy/IssueSeverityWire.cs create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj create mode 100644 Tests~/WrapperPolicy/IssueSeverityWireTests.cs diff --git a/Runtime/Contracts/Options/IssueSeverity.cs b/Runtime/Contracts/Options/IssueSeverity.cs index bb59faa..7d7a7a5 100644 --- a/Runtime/Contracts/Options/IssueSeverity.cs +++ b/Runtime/Contracts/Options/IssueSeverity.cs @@ -1,3 +1,6 @@ +using System; +using Bugsee.WrapperPolicy; + namespace Bugsee.Contracts.Options { public enum IssueSeverity @@ -13,15 +16,12 @@ public static class IssueSeverityExtensions { public static IssueSeverity FromIntValue(int value, IssueSeverity defaultValue = IssueSeverity.VeryLow) { - switch (value) - { - case 1: return IssueSeverity.VeryLow; - case 2: return IssueSeverity.Medium; - case 3: return IssueSeverity.High; - case 4: return IssueSeverity.Critical; - case 5: return IssueSeverity.Blocker; - default: return defaultValue; - } + if (IssueSeverityWire.TryFromWire(value, out var severity)) + return severity; + + throw new ArgumentOutOfRangeException( + nameof(value), + "severity wire value is unset or out of range " + value); } } } diff --git a/Runtime/WrapperPolicy/IssueSeverityWire.cs b/Runtime/WrapperPolicy/IssueSeverityWire.cs new file mode 100644 index 0000000..1bda89d --- /dev/null +++ b/Runtime/WrapperPolicy/IssueSeverityWire.cs @@ -0,0 +1,32 @@ +using Bugsee.Contracts.Options; + +namespace Bugsee.WrapperPolicy +{ + public static class IssueSeverityWire + { + public static bool TryFromWire(int value, out IssueSeverity severity) + { + switch (value) + { + case 1: + severity = IssueSeverity.VeryLow; + return true; + case 2: + severity = IssueSeverity.Medium; + return true; + case 3: + severity = IssueSeverity.High; + return true; + case 4: + severity = IssueSeverity.Critical; + return true; + case 5: + severity = IssueSeverity.Blocker; + return true; + default: + severity = default; + return false; + } + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + diff --git a/Tests~/WrapperPolicy/IssueSeverityWireTests.cs b/Tests~/WrapperPolicy/IssueSeverityWireTests.cs new file mode 100644 index 0000000..60e344f --- /dev/null +++ b/Tests~/WrapperPolicy/IssueSeverityWireTests.cs @@ -0,0 +1,23 @@ +using Bugsee.Contracts.Options; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IssueSeverityWireTests + { + [Test] + public void Zero_is_unset() + { + Assert.That(IssueSeverityWire.TryFromWire(0, out _), Is.False); + } + + [Test] + public void One_is_VeryLow_and_four_is_Critical() + { + Assert.That(IssueSeverityWire.TryFromWire(1, out var low), Is.True); + Assert.That(low, Is.EqualTo(IssueSeverity.VeryLow)); + Assert.That(IssueSeverityWire.TryFromWire(4, out var critical), Is.True); + Assert.That(critical, Is.EqualTo(IssueSeverity.Critical)); + } + } +} From 1e8e335ec84d9181fe8eaaafd881dfae080c11cc Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:41:03 +0500 Subject: [PATCH 006/157] Reject attribute values the native SDKs would clamp or drop. Co-authored-by: Cursor --- Runtime/WrapperPolicy/AttributePolicy.cs | 62 +++++++++++++++++++ Runtime/WrapperPolicy/UserIdentifierPolicy.cs | 19 ++++++ Tests~/WrapperPolicy/AttributePolicyTests.cs | 46 ++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 19 ++++++ 4 files changed, 146 insertions(+) create mode 100644 Runtime/WrapperPolicy/AttributePolicy.cs create mode 100644 Runtime/WrapperPolicy/UserIdentifierPolicy.cs create mode 100644 Tests~/WrapperPolicy/AttributePolicyTests.cs create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj diff --git a/Runtime/WrapperPolicy/AttributePolicy.cs b/Runtime/WrapperPolicy/AttributePolicy.cs new file mode 100644 index 0000000..e4d2dc4 --- /dev/null +++ b/Runtime/WrapperPolicy/AttributePolicy.cs @@ -0,0 +1,62 @@ +using System; + +namespace Bugsee.WrapperPolicy +{ + public struct AttributeDecision + { + public bool Accepted { get; } + public string Error { get; } + + public AttributeDecision(bool accepted, string error) + { + Accepted = accepted; + Error = error; + } + } + + public static class AttributePolicy + { + const double MaxMagnitude = 9223372036854775808d; + const int MaxStringLength = 1024; + + public static AttributeDecision Evaluate(string key, object value) + { + switch (value) + { + case string s: + if (s.Length > MaxStringLength) + return new AttributeDecision(false, "attribute '" + key + "' exceeds 1024 UTF-16 units"); + return new AttributeDecision(true, null); + case bool _: + return new AttributeDecision(true, null); + case byte _: + case short _: + case int _: + case long _: + return new AttributeDecision(true, null); + case float f: + return EvaluateFloating(key, f); + case double d: + return EvaluateFloating(key, d); + default: + return new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + } + } + + static AttributeDecision EvaluateFloating(string key, float value) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + return new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + return EvaluateFloating(key, (double)value); + } + + static AttributeDecision EvaluateFloating(string key, double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + return new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + if (value >= MaxMagnitude || value <= -MaxMagnitude) + return new AttributeDecision(false, "attribute '" + key + "' exceeds 9223372036854775808"); + return new AttributeDecision(true, null); + } + } +} diff --git a/Runtime/WrapperPolicy/UserIdentifierPolicy.cs b/Runtime/WrapperPolicy/UserIdentifierPolicy.cs new file mode 100644 index 0000000..1e7e7e1 --- /dev/null +++ b/Runtime/WrapperPolicy/UserIdentifierPolicy.cs @@ -0,0 +1,19 @@ +namespace Bugsee.WrapperPolicy +{ + public static class UserIdentifierPolicy + { + public static string ForSet(string value) + { + if (value == null || value == "") + return null; + return value; + } + + public static string ForGet(string value) + { + if (value == null || value == "") + return null; + return value; + } + } +} diff --git a/Tests~/WrapperPolicy/AttributePolicyTests.cs b/Tests~/WrapperPolicy/AttributePolicyTests.cs new file mode 100644 index 0000000..99e6f61 --- /dev/null +++ b/Tests~/WrapperPolicy/AttributePolicyTests.cs @@ -0,0 +1,46 @@ +using System; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AttributePolicyTests + { + [Test] + public void Long_max_is_accepted_and_two_to_the_63_is_rejected_without_echoing_it() + { + var ok = AttributePolicy.Evaluate("lives", long.MaxValue); + Assert.That(ok.Accepted, Is.True); + + double tooBig = 9223372036854775808d; + var bad = AttributePolicy.Evaluate("lives", tooBig); + Assert.That(bad.Accepted, Is.False); + Assert.That(bad.Error, Does.Contain("lives")); + Assert.That(bad.Error, Does.Not.Contain(tooBig.ToString())); + } + + [Test] + public void Non_finite_and_unsupported_types_are_rejected() + { + Assert.That(AttributePolicy.Evaluate("n", double.NaN).Accepted, Is.False); + Assert.That(AttributePolicy.Evaluate("when", DateTime.UtcNow).Accepted, Is.False); + } + + [Test] + public void String_over_1024_utf16_units_is_rejected() + { + var bad = AttributePolicy.Evaluate("note", new string('a', 1025)); + Assert.That(bad.Accepted, Is.False); + Assert.That(bad.Error, Does.Contain("1024")); + Assert.That(bad.Error, Does.Not.Contain("aaaa")); + } + + [Test] + public void Empty_user_identifier_clears_on_set_and_get() + { + Assert.That(UserIdentifierPolicy.ForSet(null), Is.Null); + Assert.That(UserIdentifierPolicy.ForSet(""), Is.Null); + Assert.That(UserIdentifierPolicy.ForSet("ada"), Is.EqualTo("ada")); + Assert.That(UserIdentifierPolicy.ForGet(""), Is.Null); + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + From fc6d346cdcffa89a079fad1d6b6624c39ff67ea6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:42:23 +0500 Subject: [PATCH 007/157] Publish secure rectangles in native units with a per-display version. Co-authored-by: Cursor --- Runtime/WrapperPolicy/SecureRectRegistry.cs | 120 ++++++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 19 +++ .../WrapperPolicy/SecureRectRegistryTests.cs | 33 +++++ 3 files changed, 172 insertions(+) create mode 100644 Runtime/WrapperPolicy/SecureRectRegistry.cs create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj create mode 100644 Tests~/WrapperPolicy/SecureRectRegistryTests.cs diff --git a/Runtime/WrapperPolicy/SecureRectRegistry.cs b/Runtime/WrapperPolicy/SecureRectRegistry.cs new file mode 100644 index 0000000..0a8a19e --- /dev/null +++ b/Runtime/WrapperPolicy/SecureRectRegistry.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + public sealed class SecureRectRegistry + { + readonly Dictionary<(string ownerId, int displayId), int[]> _rectsByOwnerDisplay = + new Dictionary<(string ownerId, int displayId), int[]>(); + + readonly Dictionary _displayStates = + new Dictionary(); + + sealed class DisplaySnapshotState + { + public int Version; + public int[] LastPublishedRects; + } + + public void Set(string ownerId, int displayId, int left, int top, int right, int bottom) + { + _rectsByOwnerDisplay[(ownerId, displayId)] = new[] { left, top, right, bottom }; + } + + public void RemoveOwner(string ownerId) + { + var keysToRemove = new List<(string ownerId, int displayId)>(); + foreach (var key in _rectsByOwnerDisplay.Keys) + { + if (key.ownerId == ownerId) + keysToRemove.Add(key); + } + + for (int i = 0; i < keysToRemove.Count; i++) + _rectsByOwnerDisplay.Remove(keysToRemove[i]); + } + + public int[] Snapshot(int displayId, float pixelsPerNativeUnit) + { + if (pixelsPerNativeUnit <= 0f) + throw new ArgumentOutOfRangeException(nameof(pixelsPerNativeUnit)); + + int[] converted = BuildConvertedRects(displayId, pixelsPerNativeUnit); + DisplaySnapshotState state = GetOrCreateDisplayState(displayId); + + if (state.LastPublishedRects == null) + { + state.Version = 1; + state.LastPublishedRects = (int[])converted.Clone(); + } + else if (!RectBuffersEqual(state.LastPublishedRects, converted)) + { + state.Version++; + state.LastPublishedRects = (int[])converted.Clone(); + } + + return PackSnapshot(state.Version, converted); + } + + static int[] PackSnapshot(int version, int[] convertedRects) + { + int rectCount = convertedRects.Length / 4; + var result = new int[2 + convertedRects.Length]; + result[0] = version; + result[1] = rectCount; + Array.Copy(convertedRects, 0, result, 2, convertedRects.Length); + return result; + } + + int[] BuildConvertedRects(int displayId, float pixelsPerNativeUnit) + { + var owners = new List(); + foreach (var key in _rectsByOwnerDisplay.Keys) + { + if (key.displayId == displayId) + owners.Add(key.ownerId); + } + + owners.Sort(StringComparer.Ordinal); + + var converted = new int[owners.Count * 4]; + for (int i = 0; i < owners.Count; i++) + { + int[] rect = _rectsByOwnerDisplay[(owners[i], displayId)]; + int offset = i * 4; + converted[offset] = (int)Math.Floor(rect[0] / pixelsPerNativeUnit); + converted[offset + 1] = (int)Math.Floor(rect[1] / pixelsPerNativeUnit); + converted[offset + 2] = (int)Math.Ceiling(rect[2] / pixelsPerNativeUnit); + converted[offset + 3] = (int)Math.Ceiling(rect[3] / pixelsPerNativeUnit); + } + + return converted; + } + + DisplaySnapshotState GetOrCreateDisplayState(int displayId) + { + if (!_displayStates.TryGetValue(displayId, out DisplaySnapshotState state)) + { + state = new DisplaySnapshotState(); + _displayStates[displayId] = state; + } + + return state; + } + + static bool RectBuffersEqual(int[] left, int[] right) + { + if (left.Length != right.Length) + return false; + + for (int i = 0; i < left.Length; i++) + { + if (left[i] != right[i]) + return false; + } + + return true; + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + diff --git a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs new file mode 100644 index 0000000..9b33d22 --- /dev/null +++ b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class SecureRectRegistryTests + { + [Test] + public void Ios_scale_3_rounds_outward_after_convert() + { + var registry = new SecureRectRegistry(); + registry.Set("hud", 0, 0, 0, 10, 10); + int[] snap = registry.Snapshot(0, 3f); + Assert.That(snap, Is.EqualTo(new[] { 1, 1, 0, 0, 4, 4 })); + } + + [Test] + public void Same_rect_does_not_bump_version_and_other_owner_survives() + { + var registry = new SecureRectRegistry(); + registry.Set("a", 0, 0, 0, 2, 2); + int version = registry.Snapshot(0, 1f)[0]; + registry.Set("a", 0, 0, 0, 2, 2); + Assert.That(registry.Snapshot(0, 1f)[0], Is.EqualTo(version)); + + registry.Set("b", 0, 5, 5, 8, 8); + registry.RemoveOwner("a"); + int[] snap = registry.Snapshot(0, 1f); + Assert.That(snap[0], Is.GreaterThan(version)); + Assert.That(snap[1], Is.EqualTo(1)); + Assert.That(snap[2], Is.EqualTo(5)); + } + } +} From a3196760c5ec8c094b3c2141537edd26d1e676d3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:45:04 +0500 Subject: [PATCH 008/157] Register the Android wrapper before launch so crash recovery can see it. Co-authored-by: Cursor --- Plugins/Android/AndroidManifest.xml | 9 ++ Plugins/Android/UnityWrapperProvider.java | 109 ++++++++++++++++++ Runtime/Platform/Android/AndroidBridge.cs | 24 +++- .../Bugsee.WrapperPolicy.Tests.csproj | 19 +++ .../WrapperPolicy/RegistrationSourceTests.cs | 46 ++++++++ 5 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 Plugins/Android/AndroidManifest.xml create mode 100644 Plugins/Android/UnityWrapperProvider.java create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj create mode 100644 Tests~/WrapperPolicy/RegistrationSourceTests.cs diff --git a/Plugins/Android/AndroidManifest.xml b/Plugins/Android/AndroidManifest.xml new file mode 100644 index 0000000..2b7c84f --- /dev/null +++ b/Plugins/Android/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java new file mode 100644 index 0000000..d57e644 --- /dev/null +++ b/Plugins/Android/UnityWrapperProvider.java @@ -0,0 +1,109 @@ +package com.bugsee.unity; + +import com.bugsee.library.Bugsee; +import com.bugsee.library.BugseeExtensionInitProviderBase; +import com.bugsee.library.contracts.common.DataRequestResultCallback; +import com.bugsee.library.contracts.internal.BugseeWrapper; +import com.bugsee.library.contracts.internal.BugseeWrapperChannel; +import com.bugsee.library.contracts.reporting.Report; +import java.util.HashMap; +import java.util.Map; + +// initOrder 200 +public final class UnityWrapperProvider extends BugseeExtensionInitProviderBase { + @Override + protected boolean onExtensionCreate() { + UnityWrapper.install(); + return false; + } +} + +final class UnityWrapper implements BugseeWrapper { + private static final UnityWrapper INSTANCE = new UnityWrapper(); + private static volatile boolean installed; + + private String unityVersion = "unknown"; + private String platform = "unknown"; + private String scriptingBackend = "unknown"; + private String productName = "unknown"; + private String wrapperVersion = "unknown"; + + static void install() { + if (installed) return; + installed = true; + Bugsee.setWrapper(INSTANCE); + } + + public static void refineContext( + String unityVersion, + String platform, + String scriptingBackend, + String productName, + String wrapperVersion) { + install(); + if (unityVersion != null && unityVersion.length() > 0) INSTANCE.unityVersion = unityVersion; + if (platform != null && platform.length() > 0) INSTANCE.platform = platform; + if (scriptingBackend != null && scriptingBackend.length() > 0) { + INSTANCE.scriptingBackend = scriptingBackend; + } + if (productName != null && productName.length() > 0) INSTANCE.productName = productName; + if (wrapperVersion != null && wrapperVersion.length() > 0) { + INSTANCE.wrapperVersion = wrapperVersion; + } + } + + @Override + public String getWrapperType() { + return "unity"; + } + + @Override + public String getWrapperVersion() { + return wrapperVersion; + } + + @Override + public String getWrapperBuild() { + return "unknown"; + } + + @Override + public Map getContext() { + HashMap map = new HashMap(); + map.put("unity_version", unityVersion); + map.put("unity_platform", platform); + map.put("scripting_backend", scriptingBackend); + map.put("product_name", productName); + return map; + } + + @Override + public void requestData(String dataType, DataRequestResultCallback callback) { + if (callback != null) callback.onResult(null); + } + + @Override + public void onLifecycleEvent(String eventType, Object data) { + // Wrapper lifecycle is forwarded via setLifecycleEventsListener on the Bugsee facade. + } + + @Override + public void onWrapperChannelAvailable(BugseeWrapperChannel value) { + // Task 9 stores the channel for host log/network submission. + } + + @Override + public int[] getSecureRectangles(int display) { + return new int[] { 1, 0 }; + } + + @Override + public void onBeforeReportCreated(Report report, boolean isTerminating, Runnable completion) { + if (completion != null) completion.run(); + } + + @Override + public void onAfterReportCreated(Report report, boolean isTerminating, Runnable completion) { + if (completion != null) completion.run(); + } +} diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index f9722ed..107caee 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -7,6 +7,7 @@ using Bugsee.Contracts.Lifecycle; using Bugsee.Contracts.Options; using Bugsee.Contracts.Reporting; +using Bugsee; using Bugsee.Internal; using Bugsee.Platform; using UnityEngine; @@ -19,7 +20,7 @@ sealed class AndroidBridge : IBugseeNativeBridge const string FeedbackClass = "com.bugsee.library.contracts.extensions.Feedback"; readonly AndroidJavaClass _bugsee = new AndroidJavaClass(BugseeClass); - BugseeWrapperProxy _wrapper; + bool _wrapperContextRefined; ReportHandlerProxy _reportHandlerProxy; LifecycleListenerProxy _lifecycleProxy; EventFilterProxy _networkFilterProxy; @@ -34,10 +35,23 @@ sealed class AndroidBridge : IBugseeNativeBridge public void EnsureWrapperRegistered() { - if (_wrapper != null) return; - BugseeWrapperProxy.CacheHostContext(); - _wrapper = new BugseeWrapperProxy(); - _bugsee.CallStatic("setWrapper", _wrapper); + if (_wrapperContextRefined) return; + _wrapperContextRefined = true; +#if ENABLE_IL2CPP + const string scriptingBackend = "il2cpp"; +#else + const string scriptingBackend = "mono"; +#endif + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) + { + wrapper.CallStatic( + "refineContext", + Application.unityVersion ?? "unknown", + Application.platform.ToString(), + scriptingBackend, + Application.productName ?? "unknown", + BugseePackageVersion.Version); + } } AndroidJavaObject CurrentActivity() diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + diff --git a/Tests~/WrapperPolicy/RegistrationSourceTests.cs b/Tests~/WrapperPolicy/RegistrationSourceTests.cs new file mode 100644 index 0000000..2ff8652 --- /dev/null +++ b/Tests~/WrapperPolicy/RegistrationSourceTests.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class RegistrationSourceTests + { + static string FindRepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return dir; + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string RepoFile(string relative) + { + return Path.GetFullPath(Path.Combine(FindRepoRoot(), relative)); + } + + [Test] + public void AndroidBridge_source_does_not_call_setWrapper() + { + string text = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(text, Does.Not.Contain("\"setWrapper\"")); + } + + [Test] + public void Provider_class_name_survives_the_gradle_plugin_strip() + { + string java = File.ReadAllText(RepoFile("Plugins/Android/UnityWrapperProvider.java")); + Assert.That(Regex.IsMatch(java, @"class\s+Bugsee[A-Za-z0-9_]+InitProvider\b"), Is.False); + Assert.That(java, Does.Contain("class UnityWrapperProvider")); + Assert.That(java, Does.Contain("initOrder")); + Assert.That(java, Does.Contain("Bugsee.setWrapper")); + } + } +} From ef00239b6ddef69d2e6e91fc7f5d8b6e8599f88e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:46:47 +0500 Subject: [PATCH 009/157] Address review: nullable report severity and Unity meta. FromIntValue honors defaultValue again; iOS/Android report reads use TryFromWire so wire 0 is null, not VeryLow. Add WrapperPolicy .meta files. Co-authored-by: Cursor --- Runtime/Contracts/Options/IssueSeverity.cs | 5 +---- Runtime/Contracts/Reporting/IReport.cs | 2 +- Runtime/Platform/Android/AndroidReport.cs | 10 +++++++--- Runtime/Platform/IOS/IosReport.cs | 7 ++++--- Runtime/WrapperPolicy/IssueSeverityWire.cs.meta | 11 +++++++++++ Runtime/WrapperPolicy/WrapperPolicy.meta | 8 ++++++++ Tests~/WrapperPolicy/IssueSeverityWireTests.cs | 14 ++++++++++++++ 7 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 Runtime/WrapperPolicy/IssueSeverityWire.cs.meta create mode 100644 Runtime/WrapperPolicy/WrapperPolicy.meta diff --git a/Runtime/Contracts/Options/IssueSeverity.cs b/Runtime/Contracts/Options/IssueSeverity.cs index 7d7a7a5..b6280c7 100644 --- a/Runtime/Contracts/Options/IssueSeverity.cs +++ b/Runtime/Contracts/Options/IssueSeverity.cs @@ -1,4 +1,3 @@ -using System; using Bugsee.WrapperPolicy; namespace Bugsee.Contracts.Options @@ -19,9 +18,7 @@ public static IssueSeverity FromIntValue(int value, IssueSeverity defaultValue = if (IssueSeverityWire.TryFromWire(value, out var severity)) return severity; - throw new ArgumentOutOfRangeException( - nameof(value), - "severity wire value is unset or out of range " + value); + return defaultValue; } } } diff --git a/Runtime/Contracts/Reporting/IReport.cs b/Runtime/Contracts/Reporting/IReport.cs index 717087f..23d3aa9 100644 --- a/Runtime/Contracts/Reporting/IReport.cs +++ b/Runtime/Contracts/Reporting/IReport.cs @@ -14,7 +14,7 @@ public interface IReport string Summary { get; set; } string Description { get; set; } string Email { get; set; } - IssueSeverity Severity { get; set; } + IssueSeverity? Severity { get; set; } IReadOnlyDictionary Attributes { get; } object GetAttribute(string name); diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index 910e6f7..d37305f 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -4,6 +4,7 @@ using System.Text; using Bugsee.Contracts.Options; using Bugsee.Contracts.Reporting; +using Bugsee.WrapperPolicy; using UnityEngine; namespace Bugsee.Platform.Android @@ -50,20 +51,23 @@ public string Email set => _report.Call("setEmail", value); } - public IssueSeverity Severity + public IssueSeverity? Severity { get { using (var s = _report.Call("getSeverity")) { var v = s?.Call("getValue") ?? (int)IssueSeverity.High; - return IssueSeverityExtensions.FromIntValue(v); + return IssueSeverityWire.TryFromWire(v, out var severity) ? severity : (IssueSeverity?)null; } } set { + if (!value.HasValue) + return; + using (var clazz = new AndroidJavaClass("com.bugsee.library.contracts.options.IssueSeverity")) - using (var sev = clazz.CallStatic("fromIntValue", (int)value)) + using (var sev = clazz.CallStatic("fromIntValue", (int)value.Value)) { _report.Call("setSeverity", sev); } diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index 478f593..fd6b095 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -4,6 +4,7 @@ using System.Text; using Bugsee.Contracts.Options; using Bugsee.Contracts.Reporting; +using Bugsee.WrapperPolicy; namespace Bugsee.Platform.IOS { @@ -80,10 +81,10 @@ public string Email set => _dto.email = value; } - public IssueSeverity Severity + public IssueSeverity? Severity { - get => IssueSeverityExtensions.FromIntValue(_dto.severity); - set => _dto.severity = (int)value; + get => IssueSeverityWire.TryFromWire(_dto.severity, out var severity) ? severity : (IssueSeverity?)null; + set => _dto.severity = value.HasValue ? (int)value.Value : 0; } public IReadOnlyDictionary Attributes => _attributes; diff --git a/Runtime/WrapperPolicy/IssueSeverityWire.cs.meta b/Runtime/WrapperPolicy/IssueSeverityWire.cs.meta new file mode 100644 index 0000000..f98f670 --- /dev/null +++ b/Runtime/WrapperPolicy/IssueSeverityWire.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d5f0b3c2e9a4f7b0c4d6e8f90123456 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/WrapperPolicy.meta b/Runtime/WrapperPolicy/WrapperPolicy.meta new file mode 100644 index 0000000..e0d3968 --- /dev/null +++ b/Runtime/WrapperPolicy/WrapperPolicy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c4e9a2b1d8f4e6a9b3c5d7e8f901234 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/IssueSeverityWireTests.cs b/Tests~/WrapperPolicy/IssueSeverityWireTests.cs index 60e344f..24351b1 100644 --- a/Tests~/WrapperPolicy/IssueSeverityWireTests.cs +++ b/Tests~/WrapperPolicy/IssueSeverityWireTests.cs @@ -19,5 +19,19 @@ public void One_is_VeryLow_and_four_is_Critical() Assert.That(IssueSeverityWire.TryFromWire(4, out var critical), Is.True); Assert.That(critical, Is.EqualTo(IssueSeverity.Critical)); } + + [Test] + public void FromIntValue_zero_honors_defaultValue() + { + Assert.That(IssueSeverityExtensions.FromIntValue(0, IssueSeverity.High), Is.EqualTo(IssueSeverity.High)); + } + + [Test] + public void Wire_zero_maps_to_null_severity_like_ios_report() + { + Assert.That(IssueSeverityWire.TryFromWire(0, out _), Is.False); + IssueSeverity? severity = IssueSeverityWire.TryFromWire(0, out var parsed) ? parsed : null; + Assert.That(severity, Is.Null); + } } } From ff35b9c822f109b1f151854ebe5f8a0d58aca713 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:50:17 +0500 Subject: [PATCH 010/157] Patch the Android Gradle files with a marked plugin line that is safe to apply twice. Co-authored-by: Cursor --- Editor/BugseeAndroidGradleSetup.cs | 49 ++- Runtime/WrapperPolicy/MarkedGradleBlock.cs | 289 ++++++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 19 ++ .../WrapperPolicy/MarkedGradleBlockTests.cs | 31 ++ 4 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 Runtime/WrapperPolicy/MarkedGradleBlock.cs create mode 100644 Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj create mode 100644 Tests~/WrapperPolicy/MarkedGradleBlockTests.cs diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index e5d3eba..b86d052 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using Bugsee.WrapperPolicy; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; @@ -25,7 +26,6 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport public const string SdkVersion = "7.3.0"; const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; - const string Marker = "// Bugsee Gradle plugin"; const string KotlinExcludeMarker = "// Bugsee: bugsee-android Gradle .module"; static BugseeAndroidGradleSetup() @@ -162,6 +162,19 @@ static int GetUnityMajorVersion() static void WriteBaseProjectTemplate(bool applyPlugin) { string path = Path.Combine(Application.dataPath, "Plugins", "Android", "baseProjectTemplate.gradle"); + if (File.Exists(path)) + { + if (applyPlugin) + { + TryApplyMarkedGradle( + path, + "plugins {", + $" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false // bugsee:gradle-plugin"); + } + + return; + } + var sb = new StringBuilder(); sb.AppendLine("plugins {"); sb.AppendLine(" // If you are changing the Android Gradle Plugin version, make sure it is compatible with the Gradle version preinstalled with Unity"); @@ -172,8 +185,8 @@ static void WriteBaseProjectTemplate(bool applyPlugin) sb.AppendLine(" id 'com.android.library' version '7.4.2' apply false"); if (applyPlugin) { - sb.AppendLine($" {Marker}"); - sb.AppendLine($" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false"); + sb.AppendLine( + $" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false // bugsee:gradle-plugin"); } sb.AppendLine(" **BUILD_SCRIPT_DEPS**"); @@ -189,12 +202,24 @@ static void WriteBaseProjectTemplate(bool applyPlugin) static void WriteLauncherTemplate(bool applyPlugin) { string path = Path.Combine(Application.dataPath, "Plugins", "Android", "launcherTemplate.gradle"); + if (File.Exists(path)) + { + if (applyPlugin) + { + TryApplyMarkedGradle( + path, + "apply plugin: 'com.android.application'", + "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin"); + } + + return; + } + var sb = new StringBuilder(); sb.AppendLine("apply plugin: 'com.android.application'"); if (applyPlugin) { - sb.AppendLine(Marker); - sb.AppendLine("apply plugin: 'com.bugsee.android.gradle'"); + sb.AppendLine("apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin"); sb.AppendLine(); sb.AppendLine("bugsee {"); sb.AppendLine(" // App token is supplied at runtime via Bugsee.Launch."); @@ -272,6 +297,20 @@ static void WriteLauncherTemplate(bool applyPlugin) WriteIfChanged(path, sb.ToString()); } + static void TryApplyMarkedGradle(string path, string anchorLine, string markedLine) + { + try + { + string existing = File.ReadAllText(path); + string updated = MarkedGradleBlock.Apply(existing, anchorLine, markedLine); + WriteIfChanged(path, updated); + } + catch (InvalidOperationException ex) + { + Debug.LogWarning(ex.Message); + } + } + static void WriteIfChanged(string path, string contents) { if (File.Exists(path) && File.ReadAllText(path) == contents) diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs new file mode 100644 index 0000000..951ce28 --- /dev/null +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + public static class MarkedGradleBlock + { + const string MarkerComment = "// bugsee:gradle-plugin"; + const string LegacyMarkerComment = "// Bugsee Gradle plugin"; + const string BugseeGradlePluginId = "com.bugsee.android.gradle"; + + public static string Apply(string existing, string anchorLine, string markedLine) + { + if (existing == null) + { + throw new ArgumentNullException(nameof(existing)); + } + + if (anchorLine == null) + { + throw new ArgumentNullException(nameof(anchorLine)); + } + + if (markedLine == null) + { + throw new ArgumentNullException(nameof(markedLine)); + } + + if (IsAlreadyPatched(existing, markedLine)) + { + return existing; + } + + var lines = SplitLines(existing, out string newline); + int depth = 0; + bool inBlockComment = false; + int anchorIndex = -1; + int matchCount = 0; + + for (int i = 0; i < lines.Count; i++) + { + string line = lines[i]; + if (!inBlockComment && depth == 0 && + string.Equals(line, anchorLine, StringComparison.Ordinal)) + { + if (HasUnclosedBlockCommentOnLine(line)) + { + throw Refuse(anchorLine); + } + + matchCount++; + anchorIndex = i; + } + + ScanLine(line, ref depth, ref inBlockComment); + } + + if (matchCount != 1) + { + throw Refuse(anchorLine); + } + + bool trailingNewline = existing.EndsWith("\n", StringComparison.Ordinal); + var sb = new StringBuilder(existing.Length + markedLine.Length + newline.Length + 8); + for (int i = 0; i < lines.Count; i++) + { + sb.Append(lines[i]); + if (i == anchorIndex) + { + sb.Append(newline); + sb.Append(markedLine); + } + + if (i < lines.Count - 1) + { + sb.Append(newline); + } + else if (trailingNewline) + { + sb.Append(newline); + } + } + + return sb.ToString(); + } + + static InvalidOperationException Refuse(string anchorLine) + { + return new InvalidOperationException( + "Gradle edit refused; expected a single anchor line: " + anchorLine); + } + + static bool IsAlreadyPatched(string existing, string markedLine) + { + if (existing.IndexOf(markedLine, StringComparison.Ordinal) >= 0) + { + return true; + } + + if (existing.IndexOf(MarkerComment, StringComparison.Ordinal) >= 0) + { + return true; + } + + if (existing.IndexOf(LegacyMarkerComment, StringComparison.Ordinal) >= 0) + { + return true; + } + + var lines = SplitLines(existing, out _); + for (int i = 0; i < lines.Count; i++) + { + string line = lines[i]; + if (line.IndexOf(BugseeGradlePluginId, StringComparison.Ordinal) < 0) + { + continue; + } + + if (line.IndexOf("id ", StringComparison.Ordinal) >= 0 || + line.IndexOf("apply plugin", StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + static bool HasUnclosedBlockCommentOnLine(string line) + { + int search = 0; + while (search < line.Length) + { + int open = IndexOfOutsideStrings(line, "/*", search); + if (open < 0) + { + return false; + } + + int close = IndexOfOutsideStrings(line, "*/", open + 2); + if (close < 0) + { + return true; + } + + search = close + 2; + } + + return false; + } + + static int IndexOfOutsideStrings(string line, string token, int start) + { + bool inSingle = false; + bool inDouble = false; + for (int i = start; i <= line.Length - token.Length; i++) + { + char c = line[i]; + if (!inSingle && !inDouble && c == '/' && i + 1 < line.Length && line[i + 1] == '/') + { + break; + } + + if (!inSingle && !inDouble && c == '/' && i + 1 < line.Length && line[i + 1] == '*') + { + if (line.AsSpan(i).StartsWith(token)) + { + return i; + } + } + + if (c == '\'' && !inDouble) + { + inSingle = !inSingle; + continue; + } + + if (c == '"' && !inSingle) + { + inDouble = !inDouble; + continue; + } + + if (!inSingle && !inDouble && line.AsSpan(i).StartsWith(token)) + { + return i; + } + } + + return -1; + } + + static List SplitLines(string text, out string newline) + { + newline = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; + var lines = new List(); + int start = 0; + for (int i = 0; i < text.Length; i++) + { + if (text[i] != '\n') + { + continue; + } + + int end = i; + if (end > 0 && text[end - 1] == '\r') + { + end--; + } + + lines.Add(text.Substring(start, end - start)); + start = i + 1; + } + + if (start <= text.Length) + { + lines.Add(text.Substring(start)); + } + + return lines; + } + + static void ScanLine(string line, ref int depth, ref bool inBlockComment) + { + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inBlockComment) + { + if (c == '*' && i + 1 < line.Length && line[i + 1] == '/') + { + inBlockComment = false; + i++; + } + + continue; + } + + if (c == '/' && i + 1 < line.Length) + { + if (line[i + 1] == '/') + { + return; + } + + if (line[i + 1] == '*') + { + inBlockComment = true; + i++; + continue; + } + } + + if (c == '\'' || c == '"') + { + char quote = c; + i++; + while (i < line.Length) + { + if (line[i] == '\\' && i + 1 < line.Length) + { + i += 2; + continue; + } + + if (line[i] == quote) + { + break; + } + + i++; + } + + continue; + } + + if (c == '{') + { + depth++; + } + else if (c == '}') + { + depth--; + } + } + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj new file mode 100644 index 0000000..e4ddb09 --- /dev/null +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -0,0 +1,19 @@ + + + net8.0 + disable + false + false + + + + + + + + + + + + + diff --git a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs new file mode 100644 index 0000000..c25cc32 --- /dev/null +++ b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs @@ -0,0 +1,31 @@ +using System; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class MarkedGradleBlockTests + { + const string Anchor = "plugins {"; + const string Marked = " id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin"; + + [Test] + public void Second_apply_is_a_no_op_and_a_commented_anchor_is_refused() + { + string once = MarkedGradleBlock.Apply(Anchor + "\n}\n", Anchor, Marked); + string twice = MarkedGradleBlock.Apply(once, Anchor, Marked); + Assert.That(twice, Is.EqualTo(once)); + + Assert.Throws(() => + MarkedGradleBlock.Apply("plugins { /*\n}\n", Anchor, Marked)); + } + + [Test] + public void Legacy_bugsee_marker_and_plugin_line_is_not_duplicated() + { + const string legacy = + Anchor + "\n // Bugsee Gradle plugin\n id 'com.bugsee.android.gradle' version '4.0.8' apply false\n}\n"; + string once = MarkedGradleBlock.Apply(legacy, Anchor, Marked); + Assert.That(once, Is.EqualTo(legacy)); + } + } +} From 47612b72e230b4654384d963021d94124071795e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:50:55 +0500 Subject: [PATCH 011/157] Accept all primitive numeric attribute types and validate names. Address automated review: TypeCode coverage for unsigned integers and decimal, non-finite floats use the magnitude error text, and empty attribute names reject. Co-authored-by: Cursor --- Runtime/WrapperPolicy/AttributePolicy.cs | 56 +++++++++++++++----- Tests~/WrapperPolicy/AttributePolicyTests.cs | 20 ++++++- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/Runtime/WrapperPolicy/AttributePolicy.cs b/Runtime/WrapperPolicy/AttributePolicy.cs index e4d2dc4..0cc0980 100644 --- a/Runtime/WrapperPolicy/AttributePolicy.cs +++ b/Runtime/WrapperPolicy/AttributePolicy.cs @@ -17,46 +17,78 @@ public AttributeDecision(bool accepted, string error) public static class AttributePolicy { const double MaxMagnitude = 9223372036854775808d; + const decimal MaxDecimalMagnitude = 9223372036854775808m; const int MaxStringLength = 1024; public static AttributeDecision Evaluate(string key, object value) { + if (string.IsNullOrEmpty(key)) + return new AttributeDecision(false, "attribute name must be non-empty"); + switch (value) { + case null: + return TypeError(key); case string s: if (s.Length > MaxStringLength) return new AttributeDecision(false, "attribute '" + key + "' exceeds 1024 UTF-16 units"); - return new AttributeDecision(true, null); + return Accept(); case bool _: - return new AttributeDecision(true, null); - case byte _: - case short _: - case int _: - case long _: - return new AttributeDecision(true, null); + return Accept(); case float f: return EvaluateFloating(key, f); case double d: return EvaluateFloating(key, d); + } + + switch (Type.GetTypeCode(value.GetType())) + { + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + case TypeCode.Int64: + return Accept(); + case TypeCode.UInt64: + return (ulong)value <= (ulong)long.MaxValue ? Accept() : MagnitudeError(key); + case TypeCode.Decimal: + return EvaluateDecimal(key, (decimal)value); default: - return new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + return TypeError(key); } } + static AttributeDecision Accept() => new AttributeDecision(true, null); + + static AttributeDecision TypeError(string key) => + new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + + static AttributeDecision MagnitudeError(string key) => + new AttributeDecision(false, "attribute '" + key + "' exceeds 9223372036854775808"); + + static AttributeDecision EvaluateDecimal(string key, decimal value) + { + if (value >= MaxDecimalMagnitude || value <= -MaxDecimalMagnitude) + return MagnitudeError(key); + return Accept(); + } + static AttributeDecision EvaluateFloating(string key, float value) { if (float.IsNaN(value) || float.IsInfinity(value)) - return new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + return MagnitudeError(key); return EvaluateFloating(key, (double)value); } static AttributeDecision EvaluateFloating(string key, double value) { if (double.IsNaN(value) || double.IsInfinity(value)) - return new AttributeDecision(false, "attribute '" + key + "' must be string, bool, or number"); + return MagnitudeError(key); if (value >= MaxMagnitude || value <= -MaxMagnitude) - return new AttributeDecision(false, "attribute '" + key + "' exceeds 9223372036854775808"); - return new AttributeDecision(true, null); + return MagnitudeError(key); + return Accept(); } } } diff --git a/Tests~/WrapperPolicy/AttributePolicyTests.cs b/Tests~/WrapperPolicy/AttributePolicyTests.cs index 99e6f61..eb68e49 100644 --- a/Tests~/WrapperPolicy/AttributePolicyTests.cs +++ b/Tests~/WrapperPolicy/AttributePolicyTests.cs @@ -21,10 +21,28 @@ public void Long_max_is_accepted_and_two_to_the_63_is_rejected_without_echoing_i [Test] public void Non_finite_and_unsupported_types_are_rejected() { - Assert.That(AttributePolicy.Evaluate("n", double.NaN).Accepted, Is.False); + var nan = AttributePolicy.Evaluate("n", double.NaN); + Assert.That(nan.Accepted, Is.False); + Assert.That(nan.Error, Does.Contain("9223372036854775808")); Assert.That(AttributePolicy.Evaluate("when", DateTime.UtcNow).Accepted, Is.False); } + [Test] + public void Unsigned_and_decimal_integers_inside_signed_64_bit_range_are_accepted() + { + Assert.That(AttributePolicy.Evaluate("wave", 3u).Accepted, Is.True); + Assert.That(AttributePolicy.Evaluate("id", 1UL).Accepted, Is.True); + Assert.That(AttributePolicy.Evaluate("score", 1m).Accepted, Is.True); + Assert.That(AttributePolicy.Evaluate("big", ulong.MaxValue).Accepted, Is.False); + } + + [Test] + public void Empty_attribute_name_is_rejected() + { + Assert.That(AttributePolicy.Evaluate("", "x").Accepted, Is.False); + Assert.That(AttributePolicy.Evaluate(null, true).Accepted, Is.False); + } + [Test] public void String_over_1024_utf16_units_is_rejected() { From 3e43f9bbaab4725eaf41f325d4b18dd492fb72fe Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:51:35 +0500 Subject: [PATCH 012/157] Add Unity meta files for WrapperPolicy assets. Co-authored-by: Cursor --- Runtime/WrapperPolicy.meta | 8 ++++++++ Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs.meta | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 Runtime/WrapperPolicy.meta create mode 100644 Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs.meta diff --git a/Runtime/WrapperPolicy.meta b/Runtime/WrapperPolicy.meta new file mode 100644 index 0000000..571c255 --- /dev/null +++ b/Runtime/WrapperPolicy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bd4df1b4bbeb491a9a4211ad1762f5bd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs.meta b/Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs.meta new file mode 100644 index 0000000..ff883b6 --- /dev/null +++ b/Runtime/WrapperPolicy/WrapperLogSourcePolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 17041d00b5a249b09b7d0e8a211e5bf1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From bef950ed8dbda3e75ec053b3f38e902d7bf8ccf0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:52:39 +0500 Subject: [PATCH 013/157] Address iOS wrapper review: sync lifecycle on main thread. Hop launch, relaunch, and stop from C# with RunSync so native calls complete before managed state updates. Restore ensure-wrapper registration fallback, default version metadata, host context publishing, and keep show-report async on main. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 39 ++++++-------- Plugins/iOS/BugseeUnityCallbacks.mm | 9 ++-- Runtime/Platform/IOS/IOSBridge.cs | 29 ++++++---- Runtime/Platform/IOS/IosNativeCallbacks.cs | 53 ++++++++++++++++++- .../IosRegistrationSourceTests.cs | 6 ++- 5 files changed, 94 insertions(+), 42 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 866b7e5..b51d3c9 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -46,40 +46,31 @@ static void BugseeRunOnMain(dispatch_block_t block) void _bugsee_launch(const char *appToken, const char *optionsJson) { - NSString *token = BugseeNSString(appToken); - NSString *optionsStr = optionsJson ? [NSString stringWithUTF8String:optionsJson] : nil; - BugseeRunOnMain(^{ - NSDictionary *dict = nil; - if (optionsStr) { - id parsed = BugseeDeserializeJson(optionsStr.UTF8String); - if ([parsed isKindOfClass:[NSDictionary class]]) { - dict = parsed; - } + NSDictionary *dict = nil; + if (optionsJson) { + id parsed = BugseeDeserializeJson(optionsJson); + if ([parsed isKindOfClass:[NSDictionary class]]) { + dict = parsed; } - [Bugsee launchWithToken:token andOptions:dict]; - }); + } + [Bugsee launchWithToken:BugseeNSString(appToken) andOptions:dict]; } void _bugsee_relaunch(const char *optionsJson) { - NSString *optionsStr = optionsJson ? [NSString stringWithUTF8String:optionsJson] : nil; - BugseeRunOnMain(^{ - NSDictionary *dict = nil; - if (optionsStr) { - id parsed = BugseeDeserializeJson(optionsStr.UTF8String); - if ([parsed isKindOfClass:[NSDictionary class]]) { - dict = parsed; - } + NSDictionary *dict = nil; + if (optionsJson) { + id parsed = BugseeDeserializeJson(optionsJson); + if ([parsed isKindOfClass:[NSDictionary class]]) { + dict = parsed; } - [Bugsee relaunchWithDictionaryOptions:dict]; - }); + } + [Bugsee relaunchWithDictionaryOptions:dict]; } void _bugsee_stop(void) { - BugseeRunOnMain(^{ - [Bugsee stop:nil]; - }); + [Bugsee stop:nil]; } bool _bugsee_get_launched(void) diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index c855005..664a8f4 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -36,8 +36,8 @@ static NSMutableDictionary *gPending; static int64_t gNextRequestId = 1; static NSObject *gLock; -static NSString *gWrapperVersion = nil; -static NSString *gWrapperBuild = nil; +static NSString *gWrapperVersion = @"0.1.0"; +static NSString *gWrapperBuild = @"dev"; static NSDictionary *gWrapperContext = nil; @class BugseeUnityWrapper; @@ -393,9 +393,9 @@ void _bugsee_ensure_wrapper(const char *version, const char *build) if (version) gWrapperVersion = [NSString stringWithUTF8String:version]; if (build) gWrapperBuild = [NSString stringWithUTF8String:build]; if (!gWrapper) { - NSLog(@"[Bugsee] ensure-wrapper"); - return; + gWrapper = [BugseeUnityWrapper new]; } + [Bugsee setWrapper:gWrapper]; } void _bugsee_set_wrapper_context(const char *json) @@ -406,7 +406,6 @@ void _bugsee_set_wrapper_context(const char *json) } NSDictionary *parsed = BugseeUnityParseJson(json); if (![parsed isKindOfClass:[NSDictionary class]]) { - gWrapperContext = @{}; return; } NSMutableDictionary *out = [NSMutableDictionary dictionary]; diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 1321157..5a92e0b 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -35,24 +35,33 @@ sealed class IOSBridge : IBugseeNativeBridge public void Launch(string appToken, IDictionary options) { ManagedExceptionPayload.EnsureBuildIdentity(); - EnsureWrapperRegistered(); - _bugsee_launch(appToken, ToJsonObject(options)); - _launched = true; - ExceptionPipeline.Install(this, options); + MainThreadDispatcher.RunSync(() => + { + EnsureWrapperRegistered(); + _bugsee_launch(appToken, ToJsonObject(options)); + _launched = true; + ExceptionPipeline.Install(this, options); + }); } public void Relaunch(IDictionary options) { - _bugsee_relaunch(ToJsonObject(options)); - ExceptionPipeline.Install(this, options); + MainThreadDispatcher.RunSync(() => + { + _bugsee_relaunch(ToJsonObject(options)); + ExceptionPipeline.Install(this, options); + }); } public void Stop(Action completion = null) { - ExceptionPipeline.Uninstall(); - _bugsee_stop(); - _launched = false; - completion?.Invoke(); + MainThreadDispatcher.RunSync(() => + { + ExceptionPipeline.Uninstall(); + _bugsee_stop(); + _launched = false; + completion?.Invoke(); + }); } public bool GetLaunched() => _launched && _bugsee_get_launched(); diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index f948c38..5544178 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -1,5 +1,6 @@ #if UNITY_IOS && !UNITY_EDITOR using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using AOT; using Bugsee.Contracts.Exchange; @@ -24,6 +25,7 @@ static class IosNativeCallbacks static EventFilter _logFilter; static EventFilter _breadcrumbFilter; static IReportHandler _reportHandler; + static bool _hostContextPublished; public static void EnsureRegistered() { @@ -35,7 +37,53 @@ public static void EnsureRegistered() public static void EnsureWrapper() { EnsureRegistered(); - _bugsee_ensure_wrapper(BugseePackageVersion.Version, BugseePackageVersion.Build); + void Work() + { + PublishHostContext(); + _bugsee_ensure_wrapper(BugseePackageVersion.Version, BugseePackageVersion.Build); + } + + if (MainThreadDispatcher.IsMainThread) + { + Work(); + } + else + { + MainThreadDispatcher.RunSync(Work); + } + } + + static void PublishHostContext() + { + if (_hostContextPublished) + { + return; + } + + var context = new Dictionary + { + ["unity_version"] = Application.unityVersion ?? "", + ["unity_platform"] = Application.platform.ToString(), + ["product_name"] = Application.productName ?? "", + }; + _bugsee_set_wrapper_context(WrapperContextJson(context)); + _hostContextPublished = true; + } + + static string WrapperContextJson(Dictionary map) + { + var parts = new List(map.Count); + foreach (var kv in map) + { + if (kv.Key == null || kv.Value == null) + { + continue; + } + + parts.Add(JsonString(kv.Key) + ":" + JsonString(kv.Value.ToString())); + } + + return "{" + string.Join(",", parts.ToArray()) + "}"; } public static void SetNetworkFilter(EventFilter filter) @@ -406,6 +454,9 @@ sealed class StringBox [DllImport("__Internal")] static extern void _bugsee_ensure_wrapper(string version, string build); + [DllImport("__Internal")] + static extern void _bugsee_set_wrapper_context(string json); + [DllImport("__Internal")] static extern void _bugsee_set_network_filter_enabled(int enabled); diff --git a/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs b/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs index abd9ac4..fa0ddc8 100644 --- a/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs +++ b/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs @@ -39,8 +39,10 @@ public void Wrapper_is_created_in_load_and_ensure_does_not_allocate() int ensure = text.IndexOf("void _bugsee_ensure_wrapper"); int next = text.IndexOf("void _bugsee_", ensure + 10); string body = text.Substring(ensure, next - ensure); - Assert.That(body, Does.Not.Contain("BugseeUnityWrapper new")); - Assert.That(body, Does.Not.Contain("[Bugsee setWrapper:")); + if (body.Contains("BugseeUnityWrapper new")) + { + Assert.That(body.IndexOf("if (!gWrapper)"), Is.LessThan(body.IndexOf("BugseeUnityWrapper new"))); + } } } } From 74d15ea880cc6a8f62329eb6355ee644cb9b340d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:55:52 +0500 Subject: [PATCH 014/157] Address breadcrumb review: name-based LogLevel mapping and fail-closed wire ints. Co-authored-by: Cursor --- Runtime/WrapperPolicy/BreadcrumbLevelMap.cs | 36 +++++++++++- .../WrapperPolicy/BreadcrumbLevelMapTests.cs | 55 +++++++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 1 + 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs index a55ca36..27ca7c5 100644 --- a/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs +++ b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs @@ -1,3 +1,5 @@ +using Bugsee.Contracts.Options; + namespace Bugsee.WrapperPolicy { public enum BreadcrumbLevelName @@ -26,6 +28,31 @@ public static bool TryParse(string name, out BreadcrumbLevelName level) } } + public static bool TryFromLogLevel(LogLevel logLevel, out BreadcrumbLevelName level) + { + switch (logLevel) + { + case LogLevel.Error: + level = BreadcrumbLevelName.Error; + return true; + case LogLevel.Warning: + level = BreadcrumbLevelName.Warning; + return true; + case LogLevel.Info: + level = BreadcrumbLevelName.Info; + return true; + case LogLevel.Debug: + level = BreadcrumbLevelName.Debug; + return true; + case LogLevel.Verbose: + level = BreadcrumbLevelName.Debug; + return true; + default: + level = default; + return false; + } + } + public static int ToAndroid(BreadcrumbLevelName level) { switch (level) @@ -34,7 +61,9 @@ public static int ToAndroid(BreadcrumbLevelName level) case BreadcrumbLevelName.Info: return 2; case BreadcrumbLevelName.Warning: return 3; case BreadcrumbLevelName.Error: return 4; - default: return 5; + case BreadcrumbLevelName.Fatal: return 5; + default: + throw new System.ArgumentOutOfRangeException(nameof(level), level, "Unknown breadcrumb level."); } } @@ -45,7 +74,10 @@ public static int ToIos(BreadcrumbLevelName level) case BreadcrumbLevelName.Debug: return 4; case BreadcrumbLevelName.Info: return 3; case BreadcrumbLevelName.Warning: return 2; - default: return 1; + case BreadcrumbLevelName.Error: return 1; + case BreadcrumbLevelName.Fatal: return 1; + default: + throw new System.ArgumentOutOfRangeException(nameof(level), level, "Unknown breadcrumb level."); } } } diff --git a/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs b/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs index 45996d6..b6fad35 100644 --- a/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs +++ b/Tests~/WrapperPolicy/BreadcrumbLevelMapTests.cs @@ -1,3 +1,4 @@ +using Bugsee.Contracts.Options; using NUnit.Framework; namespace Bugsee.WrapperPolicy.Tests @@ -20,11 +21,65 @@ public void Fatal_is_5_on_android_and_error_on_ios() Assert.That(BreadcrumbLevelMap.ToIos(level), Is.EqualTo(1)); } + [TestCase("debug", 1, 4)] + [TestCase("info", 2, 3)] + [TestCase("warning", 3, 2)] + [TestCase("error", 4, 1)] + [TestCase("fatal", 5, 1)] + public void All_wire_names_map_to_android_and_ios_ints(string name, int android, int ios) + { + Assert.That(BreadcrumbLevelMap.TryParse(name, out var level), Is.True); + Assert.That(BreadcrumbLevelMap.ToAndroid(level), Is.EqualTo(android)); + Assert.That(BreadcrumbLevelMap.ToIos(level), Is.EqualTo(ios)); + } + + [Test] + public void Error_and_fatal_both_map_to_ios_error() + { + Assert.That(BreadcrumbLevelMap.TryParse("error", out var error), Is.True); + Assert.That(BreadcrumbLevelMap.TryParse("fatal", out var fatal), Is.True); + Assert.That(BreadcrumbLevelMap.ToIos(error), Is.EqualTo(1)); + Assert.That(BreadcrumbLevelMap.ToIos(fatal), Is.EqualTo(1)); + Assert.That(BreadcrumbLevelMap.ToIos(error), Is.EqualTo(BreadcrumbLevelMap.ToIos(fatal))); + } + + [Test] + public void LogLevel_error_maps_to_android_error_not_info() + { + Assert.That(BreadcrumbLevelMap.TryFromLogLevel(LogLevel.Error, out var level), Is.True); + Assert.That(level, Is.EqualTo(BreadcrumbLevelName.Error)); + Assert.That(BreadcrumbLevelMap.ToAndroid(level), Is.EqualTo(4)); + Assert.That(BreadcrumbLevelMap.ToIos(level), Is.EqualTo(1)); + } + + [Test] + public void LogLevel_verbose_maps_to_debug_wire() + { + Assert.That(BreadcrumbLevelMap.TryFromLogLevel(LogLevel.Verbose, out var level), Is.True); + Assert.That(level, Is.EqualTo(BreadcrumbLevelName.Debug)); + Assert.That(BreadcrumbLevelMap.ToAndroid(level), Is.EqualTo(1)); + Assert.That(BreadcrumbLevelMap.ToIos(level), Is.EqualTo(4)); + } + [Test] public void Unknown_name_is_rejected() { Assert.That(BreadcrumbLevelMap.TryParse("verbose", out _), Is.False); Assert.That(BreadcrumbLevelMap.TryParse("Debug", out _), Is.False); } + + [Test] + public void Invalid_breadcrumb_level_name_fails_closed_on_android() + { + Assert.Throws(() => + BreadcrumbLevelMap.ToAndroid((BreadcrumbLevelName)42)); + } + + [Test] + public void Invalid_breadcrumb_level_name_fails_closed_on_ios() + { + Assert.Throws(() => + BreadcrumbLevelMap.ToIos((BreadcrumbLevelName)42)); + } } } diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index e4ddb09..07b01b8 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -13,6 +13,7 @@ + From 3cb678da2bddbc737495a2029a49ed003858fd00 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:56:04 +0500 Subject: [PATCH 015/157] Address Android wrapper packaging and minify review findings. Co-authored-by: Cursor --- Plugins/Android/AndroidManifest.xml | 1 + Plugins/Android/AndroidManifest.xml.meta | 32 +++++++++++++++++++ .../Android/UnityWrapperProvider.java.meta | 32 +++++++++++++++++++ Plugins/Android/bugsee-unity-wrapper.proguard | 5 +++ .../bugsee-unity-wrapper.proguard.meta | 32 +++++++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 Plugins/Android/AndroidManifest.xml.meta create mode 100644 Plugins/Android/UnityWrapperProvider.java.meta create mode 100644 Plugins/Android/bugsee-unity-wrapper.proguard create mode 100644 Plugins/Android/bugsee-unity-wrapper.proguard.meta diff --git a/Plugins/Android/AndroidManifest.xml b/Plugins/Android/AndroidManifest.xml index 2b7c84f..46af426 100644 --- a/Plugins/Android/AndroidManifest.xml +++ b/Plugins/Android/AndroidManifest.xml @@ -4,6 +4,7 @@ android:name="com.bugsee.unity.UnityWrapperProvider" android:authorities="${applicationId}.bugsee.unitywrapper" android:exported="false" + android:directBootAware="true" android:initOrder="200" /> diff --git a/Plugins/Android/AndroidManifest.xml.meta b/Plugins/Android/AndroidManifest.xml.meta new file mode 100644 index 0000000..cd2155b --- /dev/null +++ b/Plugins/Android/AndroidManifest.xml.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: f8b2c3d4e5f60718293a4b5c6d7e8f9a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/Android/UnityWrapperProvider.java.meta b/Plugins/Android/UnityWrapperProvider.java.meta new file mode 100644 index 0000000..617fe9e --- /dev/null +++ b/Plugins/Android/UnityWrapperProvider.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: e7a1b2c3d4e5f60718293a4b5c6d7e8f +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard new file mode 100644 index 0000000..a5e91fe --- /dev/null +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -0,0 +1,5 @@ +# C# calls com.bugsee.unity.UnityWrapper.refineContext via JNI; R8 cannot trace it. +-keep class com.bugsee.unity.UnityWrapper { + static void refineContext(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String); +} +-keep class com.bugsee.unity.UnityWrapperProvider { *; } diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard.meta b/Plugins/Android/bugsee-unity-wrapper.proguard.meta new file mode 100644 index 0000000..711aee3 --- /dev/null +++ b/Plugins/Android/bugsee-unity-wrapper.proguard.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: a1b2c3d4e5f60718293a4b5c6d7e8f9a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: From 3230ed9def88293c57d64002792c0d6721bbb318 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:56:21 +0500 Subject: [PATCH 016/157] Place WrapperPolicy folder meta beside the directory. Unity folder .meta files live next to the folder, matching Runtime/Contracts.meta. Co-authored-by: Cursor --- Runtime/{WrapperPolicy => }/WrapperPolicy.meta | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Runtime/{WrapperPolicy => }/WrapperPolicy.meta (100%) diff --git a/Runtime/WrapperPolicy/WrapperPolicy.meta b/Runtime/WrapperPolicy.meta similarity index 100% rename from Runtime/WrapperPolicy/WrapperPolicy.meta rename to Runtime/WrapperPolicy.meta From 9325bd9b770f41a1091a9547f49e94ee73cb0081 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 10:57:48 +0500 Subject: [PATCH 017/157] Harden secure rect registry from automation review. Add Unity metas, normalize inverted edges, reject non-finite scale factors, and preserve registry versions in the Android proxy cache. Co-authored-by: Cursor --- .../Android/Proxies/BugseeWrapperProxy.cs | 26 +++++++++++-------- Runtime/WrapperPolicy.meta | 8 ++++++ Runtime/WrapperPolicy/SecureRectRegistry.cs | 21 ++++++++++++--- .../WrapperPolicy/SecureRectRegistry.cs.meta | 11 ++++++++ .../WrapperPolicy/SecureRectRegistryTests.cs | 19 ++++++++++++++ 5 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 Runtime/WrapperPolicy.meta create mode 100644 Runtime/WrapperPolicy/SecureRectRegistry.cs.meta diff --git a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs b/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs index ddbab9a..00e76b1 100644 --- a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs +++ b/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs @@ -11,9 +11,8 @@ namespace Bugsee.Platform.Android /// sealed class BugseeWrapperProxy : AndroidJavaProxy { - // Packed secure-rect buffer: [version, count, l,t,r,b, ...] - int[] _secureRects = { 0, 0 }; - int _secureVersion = 1; + // Packed secure-rect buffer per display: [version, count, l,t,r,b, ...] + readonly Dictionary _secureRectsByDisplay = new Dictionary(); public BugseeWrapperProxy() : base("com.bugsee.library.contracts.internal.BugseeWrapper") @@ -75,8 +74,10 @@ public void onLifecycleEvent(string eventType, string data) public int[] getSecureRectangles(int display) { - // display id reserved for multi-display; Unity currently publishes one set. - return _secureRects; + if (_secureRectsByDisplay.TryGetValue(display, out int[] cached)) + return cached; + + return new[] { 1, 0 }; } // ReportHandler defaults — no-op continue. App handler is separate via setReportHandler. @@ -91,19 +92,22 @@ public void onAfterReportCreated(AndroidJavaObject report, bool isTerminating, A } internal void SetSecureRectangles(IList packedOrEmpty) + { + SetSecureRectangles(0, packedOrEmpty); + } + + internal void SetSecureRectangles(int display, IList packedOrEmpty) { if (packedOrEmpty == null || packedOrEmpty.Count < 2) { - _secureVersion++; - _secureRects = new[] { _secureVersion, 0 }; + _secureRectsByDisplay[display] = new[] { 1, 0 }; return; } - _secureVersion++; var arr = new int[packedOrEmpty.Count]; - for (var i = 0; i < packedOrEmpty.Count; i++) arr[i] = packedOrEmpty[i]; - arr[0] = _secureVersion; - _secureRects = arr; + for (var i = 0; i < packedOrEmpty.Count; i++) + arr[i] = packedOrEmpty[i]; + _secureRectsByDisplay[display] = arr; } } } diff --git a/Runtime/WrapperPolicy.meta b/Runtime/WrapperPolicy.meta new file mode 100644 index 0000000..d768e7a --- /dev/null +++ b/Runtime/WrapperPolicy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b8153596aeb745909a7230ec4681a4c0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/SecureRectRegistry.cs b/Runtime/WrapperPolicy/SecureRectRegistry.cs index 0a8a19e..800b9cf 100644 --- a/Runtime/WrapperPolicy/SecureRectRegistry.cs +++ b/Runtime/WrapperPolicy/SecureRectRegistry.cs @@ -19,7 +19,17 @@ sealed class DisplaySnapshotState public void Set(string ownerId, int displayId, int left, int top, int right, int bottom) { - _rectsByOwnerDisplay[(ownerId, displayId)] = new[] { left, top, right, bottom }; + int normalizedLeft = Math.Min(left, right); + int normalizedRight = Math.Max(left, right); + int normalizedTop = Math.Min(top, bottom); + int normalizedBottom = Math.Max(top, bottom); + _rectsByOwnerDisplay[(ownerId, displayId)] = new[] + { + normalizedLeft, + normalizedTop, + normalizedRight, + normalizedBottom + }; } public void RemoveOwner(string ownerId) @@ -37,8 +47,7 @@ public void RemoveOwner(string ownerId) public int[] Snapshot(int displayId, float pixelsPerNativeUnit) { - if (pixelsPerNativeUnit <= 0f) - throw new ArgumentOutOfRangeException(nameof(pixelsPerNativeUnit)); + ValidatePixelsPerNativeUnit(pixelsPerNativeUnit); int[] converted = BuildConvertedRects(displayId, pixelsPerNativeUnit); DisplaySnapshotState state = GetOrCreateDisplayState(displayId); @@ -103,6 +112,12 @@ DisplaySnapshotState GetOrCreateDisplayState(int displayId) return state; } + static void ValidatePixelsPerNativeUnit(float pixelsPerNativeUnit) + { + if (pixelsPerNativeUnit <= 0f || float.IsNaN(pixelsPerNativeUnit) || float.IsInfinity(pixelsPerNativeUnit)) + throw new ArgumentOutOfRangeException(nameof(pixelsPerNativeUnit)); + } + static bool RectBuffersEqual(int[] left, int[] right) { if (left.Length != right.Length) diff --git a/Runtime/WrapperPolicy/SecureRectRegistry.cs.meta b/Runtime/WrapperPolicy/SecureRectRegistry.cs.meta new file mode 100644 index 0000000..4ab3dd9 --- /dev/null +++ b/Runtime/WrapperPolicy/SecureRectRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c844f4a06a5b4806afb8cfa9bb90e91e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs index 9b33d22..8f4c383 100644 --- a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs +++ b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs @@ -1,3 +1,4 @@ +using System; using NUnit.Framework; namespace Bugsee.WrapperPolicy.Tests @@ -29,5 +30,23 @@ public void Same_rect_does_not_bump_version_and_other_owner_survives() Assert.That(snap[1], Is.EqualTo(1)); Assert.That(snap[2], Is.EqualTo(5)); } + + [TestCase(float.NaN)] + [TestCase(float.PositiveInfinity)] + public void Non_finite_scale_throws(float scale) + { + var registry = new SecureRectRegistry(); + registry.Set("hud", 0, 0, 0, 10, 10); + Assert.Throws(() => registry.Snapshot(0, scale)); + } + + [Test] + public void Inverted_edges_normalize_before_publish() + { + var registry = new SecureRectRegistry(); + registry.Set("hud", 0, 100, 200, 40, 80); + int[] snap = registry.Snapshot(0, 1f); + Assert.That(snap, Is.EqualTo(new[] { 1, 1, 40, 80, 100, 200 })); + } } } From 85a28542a86529698f460b0a4d220c7f1e2e5215 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:02:36 +0500 Subject: [PATCH 018/157] Harden main-thread lifecycle hops for iOS launch/stop. Adopt the player thread when the dispatcher is not bootstrapped yet, drain queued work before inline lifecycle calls, skip RunSync work on timeout, and avoid double EnsureWrapper on Launch. Co-authored-by: Cursor --- Runtime/Bugsee.cs | 1 - Runtime/Internal/MainThreadDispatcher.cs | 64 ++++++++++++++++++++---- Runtime/Platform/IOS/IOSBridge.cs | 41 +++++++++------ 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index e34ffb5..d2d1f4e 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -55,7 +55,6 @@ public static void Launch(string appToken, Dictionary options = throw new ArgumentException("appToken is required", nameof(appToken)); MainThreadDispatcher.Ensure(); - Bridge.EnsureWrapperRegistered(); Bridge.Launch(appToken, options); } diff --git a/Runtime/Internal/MainThreadDispatcher.cs b/Runtime/Internal/MainThreadDispatcher.cs index 96fd3f2..0a6f39c 100644 --- a/Runtime/Internal/MainThreadDispatcher.cs +++ b/Runtime/Internal/MainThreadDispatcher.cs @@ -15,7 +15,7 @@ public sealed class MainThreadDispatcher : MonoBehaviour [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void Bootstrap() { - _mainThreadId = Thread.CurrentThread.ManagedThreadId; + AdoptMainThreadId(); Ensure(); } @@ -28,15 +28,41 @@ public static MainThreadDispatcher Ensure() return _instance; } + static void AdoptMainThreadId() + { + if (_mainThreadId == 0) + { + _mainThreadId = Thread.CurrentThread.ManagedThreadId; + } + } + public static bool IsMainThread => - Thread.CurrentThread.ManagedThreadId == _mainThreadId; + _mainThreadId != 0 && Thread.CurrentThread.ManagedThreadId == _mainThreadId; + + static bool ShouldRunInline() => _mainThreadId == 0 || IsMainThread; + + static void DrainQueueInline() + { + while (Queue.TryDequeue(out var pending)) + { + try { pending(); } + catch (Exception ex) { Debug.LogException(ex); } + } + } + + static void RunInlineOnMainThread(Action action) + { + AdoptMainThreadId(); + DrainQueueInline(); + action(); + } public static void Run(Action action) { if (action == null) return; - if (IsMainThread) + if (ShouldRunInline()) { - action(); + RunInlineOnMainThread(action); return; } @@ -51,18 +77,32 @@ public static void Run(Action action) public static bool RunSync(Action action, int timeoutMs = 5000) { if (action == null) return true; - if (IsMainThread) + if (ShouldRunInline()) { - action(); - return true; + try + { + RunInlineOnMainThread(action); + return true; + } + catch (Exception ex) + { + Debug.LogException(ex); + throw; + } } Ensure(); using (var done = new ManualResetEventSlim(false)) { Exception captured = null; + var cancelled = 0; Queue.Enqueue(() => { + if (Interlocked.CompareExchange(ref cancelled, 0, 0) != 0) + { + return; + } + try { action(); } catch (Exception ex) { captured = ex; } finally { done.Set(); } @@ -70,23 +110,27 @@ public static bool RunSync(Action action, int timeoutMs = 5000) if (!done.Wait(timeoutMs)) { + Interlocked.Exchange(ref cancelled, 1); return false; } + if (captured != null) { throw captured; } + return true; } } void Update() { - while (Queue.TryDequeue(out var action)) + if (!IsMainThread) { - try { action(); } - catch (Exception ex) { Debug.LogException(ex); } + return; } + + DrainQueueInline(); } } } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 5a92e0b..92141fa 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -35,33 +35,42 @@ sealed class IOSBridge : IBugseeNativeBridge public void Launch(string appToken, IDictionary options) { ManagedExceptionPayload.EnsureBuildIdentity(); - MainThreadDispatcher.RunSync(() => + if (!MainThreadDispatcher.RunSync(() => + { + EnsureWrapperRegistered(); + _bugsee_launch(appToken, ToJsonObject(options)); + _launched = true; + ExceptionPipeline.Install(this, options); + })) { - EnsureWrapperRegistered(); - _bugsee_launch(appToken, ToJsonObject(options)); - _launched = true; - ExceptionPipeline.Install(this, options); - }); + Debug.LogError("[Bugsee] Launch timed out waiting for the Unity main thread."); + } } public void Relaunch(IDictionary options) { - MainThreadDispatcher.RunSync(() => + if (!MainThreadDispatcher.RunSync(() => + { + _bugsee_relaunch(ToJsonObject(options)); + ExceptionPipeline.Install(this, options); + })) { - _bugsee_relaunch(ToJsonObject(options)); - ExceptionPipeline.Install(this, options); - }); + Debug.LogError("[Bugsee] Relaunch timed out waiting for the Unity main thread."); + } } public void Stop(Action completion = null) { - MainThreadDispatcher.RunSync(() => + if (!MainThreadDispatcher.RunSync(() => + { + ExceptionPipeline.Uninstall(); + _bugsee_stop(); + _launched = false; + completion?.Invoke(); + })) { - ExceptionPipeline.Uninstall(); - _bugsee_stop(); - _launched = false; - completion?.Invoke(); - }); + Debug.LogError("[Bugsee] Stop timed out waiting for the Unity main thread."); + } } public bool GetLaunched() => _launched && _bugsee_get_launched(); From afae7a300182277cbc722252c6938163eb2e5fad Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:03:11 +0500 Subject: [PATCH 019/157] Address Gradle review: upsert marked blocks and add launcher NDK patch. Co-authored-by: Cursor --- Editor/BugseeAndroidGradleSetup.cs | 25 ++- Runtime/WrapperPolicy.meta | 8 + Runtime/WrapperPolicy/MarkedGradleBlock.cs | 201 +++++++++++++++--- .../WrapperPolicy/MarkedGradleBlock.cs.meta | 11 + .../WrapperPolicy/MarkedGradleBlockTests.cs | 73 ++++++- 5 files changed, 281 insertions(+), 37 deletions(-) create mode 100644 Runtime/WrapperPolicy.meta create mode 100644 Runtime/WrapperPolicy/MarkedGradleBlock.cs.meta diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index b86d052..78b7109 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -209,7 +209,7 @@ static void WriteLauncherTemplate(bool applyPlugin) TryApplyMarkedGradle( path, "apply plugin: 'com.android.application'", - "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin"); + LauncherMarkedGradlePatch()); } return; @@ -219,14 +219,10 @@ static void WriteLauncherTemplate(bool applyPlugin) sb.AppendLine("apply plugin: 'com.android.application'"); if (applyPlugin) { - sb.AppendLine("apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin"); - sb.AppendLine(); - sb.AppendLine("bugsee {"); - sb.AppendLine(" // App token is supplied at runtime via Bugsee.Launch."); - sb.AppendLine(" ndk {"); - sb.AppendLine(" enabled = true"); - sb.AppendLine(" }"); - sb.AppendLine("}"); + foreach (string line in LauncherMarkedGradlePatch().Split('\n')) + { + sb.AppendLine(line); + } } sb.AppendLine(); @@ -297,6 +293,17 @@ static void WriteLauncherTemplate(bool applyPlugin) WriteIfChanged(path, sb.ToString()); } + internal static string LauncherMarkedGradlePatch() + { + return "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + + "bugsee {\n" + + " // App token is supplied at runtime via Bugsee.Launch.\n" + + " ndk {\n" + + " enabled = true\n" + + " }\n" + + "} // bugsee:gradle-ndk"; + } + static void TryApplyMarkedGradle(string path, string anchorLine, string markedLine) { try diff --git a/Runtime/WrapperPolicy.meta b/Runtime/WrapperPolicy.meta new file mode 100644 index 0000000..6986370 --- /dev/null +++ b/Runtime/WrapperPolicy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4a4a3265e9364e77bd363caef0840676 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs index 951ce28..e75ba8c 100644 --- a/Runtime/WrapperPolicy/MarkedGradleBlock.cs +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -6,7 +6,8 @@ namespace Bugsee.WrapperPolicy { public static class MarkedGradleBlock { - const string MarkerComment = "// bugsee:gradle-plugin"; + internal const string MarkerComment = "// bugsee:gradle-plugin"; + internal const string NdkMarkerComment = "// bugsee:gradle-ndk"; const string LegacyMarkerComment = "// Bugsee Gradle plugin"; const string BugseeGradlePluginId = "com.bugsee.android.gradle"; @@ -27,12 +28,17 @@ public static string Apply(string existing, string anchorLine, string markedLine throw new ArgumentNullException(nameof(markedLine)); } - if (IsAlreadyPatched(existing, markedLine)) + if (ContainsNormalizedBlock(existing, markedLine)) { return existing; } var lines = SplitLines(existing, out string newline); + if (TryReplacePatchRegion(lines, markedLine, newline, existing, out string replaced)) + { + return replaced; + } + int depth = 0; bool inBlockComment = false; int anchorIndex = -1; @@ -61,17 +67,63 @@ public static string Apply(string existing, string anchorLine, string markedLine throw Refuse(anchorLine); } + return InsertAfterLine(existing, lines, newline, anchorIndex, markedLine); + } + + static bool ContainsNormalizedBlock(string existing, string markedLine) + { + return NormalizeNewlines(existing).IndexOf(NormalizeNewlines(markedLine), StringComparison.Ordinal) >= 0; + } + + static string NormalizeNewlines(string text) + { + return text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal); + } + + static bool TryReplacePatchRegion( + List lines, + string markedLine, + string newline, + string existing, + out string replaced) + { + replaced = null; + if (!TryFindPatchRegion(lines, out int start, out int end)) + { + return false; + } + + var markedLines = SplitMarkedLines(markedLine); bool trailingNewline = existing.EndsWith("\n", StringComparison.Ordinal); - var sb = new StringBuilder(existing.Length + markedLine.Length + newline.Length + 8); + var sb = new StringBuilder(existing.Length + markedLine.Length + 32); for (int i = 0; i < lines.Count; i++) { - sb.Append(lines[i]); - if (i == anchorIndex) + if (i == start) { - sb.Append(newline); - sb.Append(markedLine); + for (int m = 0; m < markedLines.Count; m++) + { + if (m > 0) + { + sb.Append(newline); + } + + sb.Append(markedLines[m]); + } + + i = end; + if (i < lines.Count - 1) + { + sb.Append(newline); + } + else if (trailingNewline) + { + sb.Append(newline); + } + + continue; } + sb.Append(lines[i]); if (i < lines.Count - 1) { sb.Append(newline); @@ -82,44 +134,74 @@ public static string Apply(string existing, string anchorLine, string markedLine } } - return sb.ToString(); + replaced = sb.ToString(); + return true; } - static InvalidOperationException Refuse(string anchorLine) + static void AppendMarkedLines(StringBuilder sb, string markedLine, string newline) { - return new InvalidOperationException( - "Gradle edit refused; expected a single anchor line: " + anchorLine); + var markedLines = SplitMarkedLines(markedLine); + for (int m = 0; m < markedLines.Count; m++) + { + if (m > 0) + { + sb.Append(newline); + } + + sb.Append(markedLines[m]); + } } - static bool IsAlreadyPatched(string existing, string markedLine) + static List SplitMarkedLines(string markedLine) { - if (existing.IndexOf(markedLine, StringComparison.Ordinal) >= 0) + string normalized = NormalizeNewlines(markedLine); + var parts = normalized.Split('\n'); + var result = new List(parts.Length); + for (int i = 0; i < parts.Length; i++) { - return true; + result.Add(parts[i]); } - if (existing.IndexOf(MarkerComment, StringComparison.Ordinal) >= 0) - { - return true; - } + return result; + } - if (existing.IndexOf(LegacyMarkerComment, StringComparison.Ordinal) >= 0) + static bool TryFindPatchRegion(List lines, out int start, out int end) + { + start = -1; + end = -1; + + for (int i = 0; i < lines.Count; i++) { - return true; + string line = lines[i]; + if (line.IndexOf(MarkerComment, StringComparison.Ordinal) >= 0) + { + start = i; + end = FindPatchEnd(lines, start); + return true; + } } - var lines = SplitLines(existing, out _); for (int i = 0; i < lines.Count; i++) { - string line = lines[i]; - if (line.IndexOf(BugseeGradlePluginId, StringComparison.Ordinal) < 0) + if (string.Equals(lines[i].Trim(), LegacyMarkerComment, StringComparison.Ordinal)) { - continue; + start = i; + end = i; + if (i + 1 < lines.Count && IsActiveBugseePluginLine(lines[i + 1])) + { + end = i + 1; + } + + return true; } + } - if (line.IndexOf("id ", StringComparison.Ordinal) >= 0 || - line.IndexOf("apply plugin", StringComparison.Ordinal) >= 0) + for (int i = 0; i < lines.Count; i++) + { + if (IsActiveBugseePluginLine(lines[i])) { + start = i; + end = FindPatchEnd(lines, start); return true; } } @@ -127,6 +209,73 @@ static bool IsAlreadyPatched(string existing, string markedLine) return false; } + static int FindPatchEnd(List lines, int start) + { + for (int i = start; i < lines.Count; i++) + { + if (lines[i].IndexOf(NdkMarkerComment, StringComparison.Ordinal) >= 0) + { + return i; + } + } + + return start; + } + + static bool IsActiveBugseePluginLine(string line) + { + if (line.IndexOf(BugseeGradlePluginId, StringComparison.Ordinal) < 0) + { + return false; + } + + string trimmed = line.TrimStart(); + if (trimmed.StartsWith("//", StringComparison.Ordinal)) + { + return false; + } + + return trimmed.IndexOf("id ", StringComparison.Ordinal) >= 0 || + trimmed.IndexOf("apply plugin", StringComparison.Ordinal) >= 0; + } + + static string InsertAfterLine( + string existing, + List lines, + string newline, + int anchorIndex, + string markedLine) + { + bool trailingNewline = existing.EndsWith("\n", StringComparison.Ordinal); + var sb = new StringBuilder(existing.Length + markedLine.Length + newline.Length + 8); + for (int i = 0; i < lines.Count; i++) + { + sb.Append(lines[i]); + if (i == anchorIndex) + { + sb.Append(newline); + AppendMarkedLines(sb, markedLine, newline); + } + + if (i < lines.Count - 1) + { + sb.Append(newline); + } + else if (trailingNewline) + { + sb.Append(newline); + } + } + + return sb.ToString(); + } + + static InvalidOperationException Refuse(string anchorLine) + { + return new InvalidOperationException( + "Gradle edit refused; expected a single anchor line: " + anchorLine); + } + static bool HasUnclosedBlockCommentOnLine(string line) { int search = 0; diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs.meta b/Runtime/WrapperPolicy/MarkedGradleBlock.cs.meta new file mode 100644 index 0000000..0587189 --- /dev/null +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f95efce42d947aa9354a6534b26375e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs index c25cc32..3224913 100644 --- a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs +++ b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using NUnit.Framework; namespace Bugsee.WrapperPolicy.Tests @@ -7,6 +8,22 @@ public class MarkedGradleBlockTests { const string Anchor = "plugins {"; const string Marked = " id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin"; + const string LauncherAnchor = "apply plugin: 'com.android.application'"; + + static string SampleLauncherTemplatePath => + Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", "..", "..", "..", "..", + "Samples~", "BugseeMiniGame", "Assets", "Plugins", "Android", "launcherTemplate.gradle")); + + static string LauncherPatch => + "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + + "bugsee {\n" + + " // App token is supplied at runtime via Bugsee.Launch.\n" + + " ndk {\n" + + " enabled = true\n" + + " }\n" + + "} // bugsee:gradle-ndk"; [Test] public void Second_apply_is_a_no_op_and_a_commented_anchor_is_refused() @@ -20,12 +37,64 @@ public void Second_apply_is_a_no_op_and_a_commented_anchor_is_refused() } [Test] - public void Legacy_bugsee_marker_and_plugin_line_is_not_duplicated() + public void Legacy_bugsee_marker_upgrades_without_duplicating_at_anchor() { const string legacy = Anchor + "\n // Bugsee Gradle plugin\n id 'com.bugsee.android.gradle' version '4.0.8' apply false\n}\n"; string once = MarkedGradleBlock.Apply(legacy, Anchor, Marked); - Assert.That(once, Is.EqualTo(legacy)); + Assert.That(once, Does.Contain("// bugsee:gradle-plugin")); + Assert.That(once, Does.Not.Contain("// Bugsee Gradle plugin")); + Assert.That(once.IndexOf("com.bugsee.android.gradle", StringComparison.Ordinal), + Is.EqualTo(once.LastIndexOf("com.bugsee.android.gradle", StringComparison.Ordinal))); + + string twice = MarkedGradleBlock.Apply(once, Anchor, Marked); + Assert.That(twice, Is.EqualTo(once)); + } + + [Test] + public void Marker_line_is_upserted_when_version_changes() + { + const string older = + Anchor + "\n id 'com.bugsee.android.gradle' version '4.0.7' apply false // bugsee:gradle-plugin\n}\n"; + const string newer = + " id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin"; + string once = MarkedGradleBlock.Apply(older, Anchor, newer); + Assert.That(once, Does.Contain("version '4.0.8'")); + Assert.That(once, Does.Not.Contain("version '4.0.7'")); + } + + [Test] + public void Commented_out_marker_is_replaced_with_active_patch() + { + const string commented = + LauncherAnchor + "\n// apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n"; + string once = MarkedGradleBlock.Apply(commented, LauncherAnchor, LauncherPatch); + Assert.That(once, Does.Contain("enabled = true")); + Assert.That(once, Does.Not.Contain("// apply plugin: 'com.bugsee.android.gradle'")); + } + + [Test] + public void Sample_launcher_gets_plugin_and_ndk_block_idempotently() + { + Assume.That(File.Exists(SampleLauncherTemplatePath), Is.True, "sample launcher template missing"); + string sample = File.ReadAllText(SampleLauncherTemplatePath); + string once = MarkedGradleBlock.Apply(sample, LauncherAnchor, LauncherPatch); + Assert.That(once, Does.Contain(MarkedGradleBlock.NdkMarkerComment)); + Assert.That(once, Does.Contain("enabled = true")); + + string twice = MarkedGradleBlock.Apply(once, LauncherAnchor, LauncherPatch); + Assert.That(twice, Is.EqualTo(once)); + } + + [Test] + public void Plugin_only_launcher_patch_adds_ndk_block() + { + const string pluginOnly = + LauncherAnchor + "\napply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n"; + string once = MarkedGradleBlock.Apply(pluginOnly, LauncherAnchor, LauncherPatch); + Assert.That(once, Does.Contain(MarkedGradleBlock.NdkMarkerComment)); + string twice = MarkedGradleBlock.Apply(once, LauncherAnchor, LauncherPatch); + Assert.That(twice, Is.EqualTo(once)); } } } From 1f3bfa22eb4cb3d4c949d9e29ce33c392620c193 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:03:52 +0500 Subject: [PATCH 020/157] Add WrapperPolicy Unity meta files aligned with sibling wrapper PRs. Co-authored-by: Cursor --- Runtime/WrapperPolicy.meta | 8 ++++++++ Runtime/WrapperPolicy/BreadcrumbLevelMap.cs.meta | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 Runtime/WrapperPolicy.meta create mode 100644 Runtime/WrapperPolicy/BreadcrumbLevelMap.cs.meta diff --git a/Runtime/WrapperPolicy.meta b/Runtime/WrapperPolicy.meta new file mode 100644 index 0000000..571c255 --- /dev/null +++ b/Runtime/WrapperPolicy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bd4df1b4bbeb491a9a4211ad1762f5bd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs.meta b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs.meta new file mode 100644 index 0000000..07aacd8 --- /dev/null +++ b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a7c3e891d4b5426f9e0b1c2d3e4f5a6b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 5765e3d96ea83646cc19f19b21bf0833c464e569 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:08:11 +0500 Subject: [PATCH 021/157] Reserve version 1 for empty secure-rect publishes. First non-empty snapshot bumps to version 2 so native polling does not ignore new masks; use a concurrent per-display cache on Android pulls. Co-authored-by: Cursor --- .../Platform/Android/Proxies/BugseeWrapperProxy.cs | 5 +++-- Runtime/WrapperPolicy/SecureRectRegistry.cs | 8 +++++--- Tests~/WrapperPolicy/SecureRectRegistryTests.cs | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs b/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs index 00e76b1..9a6cde1 100644 --- a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs +++ b/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs @@ -1,4 +1,5 @@ #if UNITY_ANDROID && !UNITY_EDITOR +using System.Collections.Concurrent; using System.Collections.Generic; using Bugsee; using UnityEngine; @@ -12,7 +13,7 @@ namespace Bugsee.Platform.Android sealed class BugseeWrapperProxy : AndroidJavaProxy { // Packed secure-rect buffer per display: [version, count, l,t,r,b, ...] - readonly Dictionary _secureRectsByDisplay = new Dictionary(); + readonly ConcurrentDictionary _secureRectsByDisplay = new ConcurrentDictionary(); public BugseeWrapperProxy() : base("com.bugsee.library.contracts.internal.BugseeWrapper") @@ -75,7 +76,7 @@ public void onLifecycleEvent(string eventType, string data) public int[] getSecureRectangles(int display) { if (_secureRectsByDisplay.TryGetValue(display, out int[] cached)) - return cached; + return (int[])cached.Clone(); return new[] { 1, 0 }; } diff --git a/Runtime/WrapperPolicy/SecureRectRegistry.cs b/Runtime/WrapperPolicy/SecureRectRegistry.cs index 800b9cf..76e771f 100644 --- a/Runtime/WrapperPolicy/SecureRectRegistry.cs +++ b/Runtime/WrapperPolicy/SecureRectRegistry.cs @@ -55,11 +55,13 @@ public int[] Snapshot(int displayId, float pixelsPerNativeUnit) if (state.LastPublishedRects == null) { state.Version = 1; - state.LastPublishedRects = (int[])converted.Clone(); + state.LastPublishedRects = Array.Empty(); } - else if (!RectBuffersEqual(state.LastPublishedRects, converted)) + + if (!RectBuffersEqual(state.LastPublishedRects, converted)) { - state.Version++; + int next = state.Version + 1; + state.Version = next == 1 ? 2 : next; state.LastPublishedRects = (int[])converted.Clone(); } diff --git a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs index 8f4c383..76f1c0c 100644 --- a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs +++ b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs @@ -11,7 +11,7 @@ public void Ios_scale_3_rounds_outward_after_convert() var registry = new SecureRectRegistry(); registry.Set("hud", 0, 0, 0, 10, 10); int[] snap = registry.Snapshot(0, 3f); - Assert.That(snap, Is.EqualTo(new[] { 1, 1, 0, 0, 4, 4 })); + Assert.That(snap, Is.EqualTo(new[] { 2, 1, 0, 0, 4, 4 })); } [Test] @@ -46,7 +46,17 @@ public void Inverted_edges_normalize_before_publish() var registry = new SecureRectRegistry(); registry.Set("hud", 0, 100, 200, 40, 80); int[] snap = registry.Snapshot(0, 1f); - Assert.That(snap, Is.EqualTo(new[] { 1, 1, 40, 80, 100, 200 })); + Assert.That(snap, Is.EqualTo(new[] { 2, 1, 40, 80, 100, 200 })); + } + + [Test] + public void Empty_snapshot_then_set_bumps_to_version_2() + { + var registry = new SecureRectRegistry(); + Assert.That(registry.Snapshot(0, 1f), Is.EqualTo(new[] { 1, 0 })); + + registry.Set("hud", 0, 0, 0, 10, 10); + Assert.That(registry.Snapshot(0, 1f)[0], Is.EqualTo(2)); } } } From 08946c46fe3fac7e1045bf666e4f6c96a41da326 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:11:34 +0500 Subject: [PATCH 022/157] Split lifecycle RunSync from exception flush path. Adopt the player thread only in Ensure and Bootstrap, restore flush RunSync without timeout cancel, and use RunSyncLifecycle for iOS launch/stop. Co-authored-by: Cursor --- Runtime/Internal/MainThreadDispatcher.cs | 103 ++++++++++++++------- Runtime/Platform/IOS/IOSBridge.cs | 6 +- Runtime/Platform/IOS/IosNativeCallbacks.cs | 1 + 3 files changed, 76 insertions(+), 34 deletions(-) diff --git a/Runtime/Internal/MainThreadDispatcher.cs b/Runtime/Internal/MainThreadDispatcher.cs index 0a6f39c..2c397bd 100644 --- a/Runtime/Internal/MainThreadDispatcher.cs +++ b/Runtime/Internal/MainThreadDispatcher.cs @@ -21,7 +21,12 @@ static void Bootstrap() public static MainThreadDispatcher Ensure() { - if (_instance != null) return _instance; + if (_instance != null) + { + return _instance; + } + + AdoptMainThreadId(); var go = new GameObject("Bugsee.MainThreadDispatcher"); DontDestroyOnLoad(go); _instance = go.AddComponent(); @@ -39,8 +44,6 @@ static void AdoptMainThreadId() public static bool IsMainThread => _mainThreadId != 0 && Thread.CurrentThread.ManagedThreadId == _mainThreadId; - static bool ShouldRunInline() => _mainThreadId == 0 || IsMainThread; - static void DrainQueueInline() { while (Queue.TryDequeue(out var pending)) @@ -52,7 +55,6 @@ static void DrainQueueInline() static void RunInlineOnMainThread(Action action) { - AdoptMainThreadId(); DrainQueueInline(); action(); } @@ -60,7 +62,7 @@ static void RunInlineOnMainThread(Action action) public static void Run(Action action) { if (action == null) return; - if (ShouldRunInline()) + if (IsMainThread) { RunInlineOnMainThread(action); return; @@ -73,11 +75,13 @@ public static void Run(Action action) /// /// Run on the main thread and block the caller until completion (or timeout). /// Used for fatal managed exception flush before process teardown. + /// Queued work is not cancelled when the wait times out. /// public static bool RunSync(Action action, int timeoutMs = 5000) { if (action == null) return true; - if (ShouldRunInline()) + Ensure(); + if (IsMainThread) { try { @@ -91,45 +95,82 @@ public static bool RunSync(Action action, int timeoutMs = 5000) } } - Ensure(); - using (var done = new ManualResetEventSlim(false)) + var done = new ManualResetEventSlim(false); + Exception captured = null; + Queue.Enqueue(() => { - Exception captured = null; - var cancelled = 0; - Queue.Enqueue(() => - { - if (Interlocked.CompareExchange(ref cancelled, 0, 0) != 0) - { - return; - } + try { action(); } + catch (Exception ex) { captured = ex; } + finally { done.Set(); } + }); - try { action(); } - catch (Exception ex) { captured = ex; } - finally { done.Set(); } - }); + if (!done.Wait(timeoutMs)) + { + return false; + } - if (!done.Wait(timeoutMs)) + if (captured != null) + { + throw captured; + } + + return true; + } + + /// + /// Like but skips the queued action if the wait times out + /// (for iOS lifecycle calls that must not run after the caller has moved on). + /// + public static bool RunSyncLifecycle(Action action, int timeoutMs = 5000) + { + if (action == null) return true; + Ensure(); + if (IsMainThread) + { + try { - Interlocked.Exchange(ref cancelled, 1); - return false; + RunInlineOnMainThread(action); + return true; } + catch (Exception ex) + { + Debug.LogException(ex); + throw; + } + } - if (captured != null) + var done = new ManualResetEventSlim(false); + Exception captured = null; + var skip = 0; + Queue.Enqueue(() => + { + if (Interlocked.CompareExchange(ref skip, 0, 0) != 0) { - throw captured; + return; } - return true; + try { action(); } + catch (Exception ex) { captured = ex; } + finally { done.Set(); } + }); + + if (!done.Wait(timeoutMs)) + { + Interlocked.Exchange(ref skip, 1); + return false; } - } - void Update() - { - if (!IsMainThread) + done.Dispose(); + if (captured != null) { - return; + throw captured; } + return true; + } + + void Update() + { DrainQueueInline(); } } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 92141fa..c485c4e 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -35,7 +35,7 @@ sealed class IOSBridge : IBugseeNativeBridge public void Launch(string appToken, IDictionary options) { ManagedExceptionPayload.EnsureBuildIdentity(); - if (!MainThreadDispatcher.RunSync(() => + if (!MainThreadDispatcher.RunSyncLifecycle(() => { EnsureWrapperRegistered(); _bugsee_launch(appToken, ToJsonObject(options)); @@ -49,7 +49,7 @@ public void Launch(string appToken, IDictionary options) public void Relaunch(IDictionary options) { - if (!MainThreadDispatcher.RunSync(() => + if (!MainThreadDispatcher.RunSyncLifecycle(() => { _bugsee_relaunch(ToJsonObject(options)); ExceptionPipeline.Install(this, options); @@ -61,7 +61,7 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { - if (!MainThreadDispatcher.RunSync(() => + if (!MainThreadDispatcher.RunSyncLifecycle(() => { ExceptionPipeline.Uninstall(); _bugsee_stop(); diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 5544178..db96c6c 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -37,6 +37,7 @@ public static void EnsureRegistered() public static void EnsureWrapper() { EnsureRegistered(); + MainThreadDispatcher.Ensure(); void Work() { PublishHostContext(); From ef394b6abc6dbf717fb0e668c1ac9bb6d432cfdf Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:17:20 +0500 Subject: [PATCH 023/157] Bound Gradle upsert regions and fix Unity 2021 string APIs. Co-authored-by: Cursor --- Runtime/WrapperPolicy/MarkedGradleBlock.cs | 450 +++++++++++++++--- .../WrapperPolicy/MarkedGradleBlockTests.cs | 33 ++ 2 files changed, 407 insertions(+), 76 deletions(-) diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs index e75ba8c..29cca4c 100644 --- a/Runtime/WrapperPolicy/MarkedGradleBlock.cs +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -33,10 +33,35 @@ public static string Apply(string existing, string anchorLine, string markedLine return existing; } + SplitPayload(markedLine, out List pluginLines, out List ndkLines); + if (IsPluginPayloadSatisfied(existing, pluginLines) && + IsNdkPayloadSatisfied(existing, ndkLines)) + { + return existing; + } var lines = SplitLines(existing, out string newline); - if (TryReplacePatchRegion(lines, markedLine, newline, existing, out string replaced)) + bool changed = false; + + if (TryUpsertPluginRegion(lines, pluginLines, ndkLines)) { - return replaced; + changed = true; + } + + if (ndkLines != null && ndkLines.Count > 0) + { + if (TryUpsertNdkRegion(lines, ndkLines)) + { + changed = true; + } + else if (TryInsertNdkAfterPlugin(lines, ndkLines)) + { + changed = true; + } + } + + if (changed) + { + return JoinLines(lines, newline, existing.EndsWith("\n", StringComparison.Ordinal)); } int depth = 0; @@ -77,106 +102,170 @@ static bool ContainsNormalizedBlock(string existing, string markedLine) static string NormalizeNewlines(string text) { - return text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal); + return text.Replace("\r\n", "\n").Replace("\r", "\n"); } - static bool TryReplacePatchRegion( - List lines, - string markedLine, - string newline, - string existing, - out string replaced) + static bool IsPluginPayloadSatisfied(string existing, List pluginLines) { - replaced = null; - if (!TryFindPatchRegion(lines, out int start, out int end)) + for (int i = 0; i < pluginLines.Count; i++) { - return false; - } + if (IsBlank(pluginLines[i])) + { + continue; + } - var markedLines = SplitMarkedLines(markedLine); - bool trailingNewline = existing.EndsWith("\n", StringComparison.Ordinal); - var sb = new StringBuilder(existing.Length + markedLine.Length + 32); - for (int i = 0; i < lines.Count; i++) - { - if (i == start) + if (existing.IndexOf(pluginLines[i], StringComparison.Ordinal) < 0) { - for (int m = 0; m < markedLines.Count; m++) - { - if (m > 0) - { - sb.Append(newline); - } + return false; + } + } - sb.Append(markedLines[m]); - } + return pluginLines.Count > 0; + } - i = end; - if (i < lines.Count - 1) - { - sb.Append(newline); - } - else if (trailingNewline) - { - sb.Append(newline); - } + static bool IsNdkPayloadSatisfied(string existing, List ndkLines) + { + if (ndkLines == null || ndkLines.Count == 0) + { + return true; + } - continue; - } + return existing.IndexOf(NdkMarkerComment, StringComparison.Ordinal) >= 0; + } - sb.Append(lines[i]); - if (i < lines.Count - 1) + static void SplitPayload(string markedLine, out List pluginLines, out List ndkLines) + { + var all = SplitMarkedLines(markedLine); + pluginLines = new List(); + ndkLines = null; + int ndkIndex = -1; + for (int i = 0; i < all.Count; i++) + { + if (all[i].TrimStart().StartsWith("bugsee {", StringComparison.Ordinal)) { - sb.Append(newline); + ndkIndex = i; + break; } - else if (trailingNewline) + } + + if (ndkIndex < 0) + { + pluginLines = all; + return; + } + + for (int i = 0; i < ndkIndex; i++) + { + if (!IsBlank(all[i])) { - sb.Append(newline); + pluginLines.Add(all[i]); } } - replaced = sb.ToString(); + ndkLines = new List(); + for (int i = ndkIndex; i < all.Count; i++) + { + ndkLines.Add(all[i]); + } + } + + static bool TryUpsertPluginRegion(List lines, List pluginLines, List ndkLines) + { + if (!TryFindPluginRegion(lines, out int start, out int end)) + { + return false; + } + + var replacement = BuildPluginReplacement(lines, start, end, pluginLines, ndkLines); + if (LinesEqual(lines, start, end, replacement)) + { + return false; + } + + ReplaceLines(lines, start, end, replacement); return true; } - static void AppendMarkedLines(StringBuilder sb, string markedLine, string newline) + static List BuildPluginReplacement( + List lines, + int start, + int end, + List pluginLines, + List ndkLines) { - var markedLines = SplitMarkedLines(markedLine); - for (int m = 0; m < markedLines.Count; m++) + var replacement = new List(pluginLines); + bool regionIncludesBugsee = false; + for (int i = start; i <= end; i++) { - if (m > 0) + if (lines[i].TrimStart().StartsWith("bugsee {", StringComparison.Ordinal)) { - sb.Append(newline); + regionIncludesBugsee = true; + break; } + } - sb.Append(markedLines[m]); + if (regionIncludesBugsee && ndkLines != null && ndkLines.Count > 0) + { + replacement.Add(string.Empty); + replacement.AddRange(ndkLines); } + + return replacement; } - static List SplitMarkedLines(string markedLine) + static bool TryUpsertNdkRegion(List lines, List ndkLines) { - string normalized = NormalizeNewlines(markedLine); - var parts = normalized.Split('\n'); - var result = new List(parts.Length); - for (int i = 0; i < parts.Length; i++) + if (!TryFindNdkRegion(lines, out int start, out int end)) { - result.Add(parts[i]); + return false; } - return result; + if (LinesEqual(lines, start, end, ndkLines)) + { + return false; + } + + ReplaceLines(lines, start, end, ndkLines); + return true; + } + + static bool TryInsertNdkAfterPlugin(List lines, List ndkLines) + { + if (TryFindNdkRegion(lines, out _, out _)) + { + return false; + } + + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].TrimStart().StartsWith("bugsee {", StringComparison.Ordinal)) + { + return false; + } + } + + if (!TryFindPluginLineIndex(lines, out int pluginIndex)) + { + return false; + } + + var insert = new List { string.Empty }; + insert.AddRange(ndkLines); + lines.InsertRange(pluginIndex + 1, insert); + return true; } - static bool TryFindPatchRegion(List lines, out int start, out int end) + static bool TryFindPluginRegion(List lines, out int start, out int end) { start = -1; end = -1; for (int i = 0; i < lines.Count; i++) { - string line = lines[i]; - if (line.IndexOf(MarkerComment, StringComparison.Ordinal) >= 0) + if (lines[i].IndexOf(MarkerComment, StringComparison.Ordinal) >= 0) { start = i; - end = FindPatchEnd(lines, start); + end = FindContiguousPluginEnd(lines, start); return true; } } @@ -192,6 +281,7 @@ static bool TryFindPatchRegion(List lines, out int start, out int end) end = i + 1; } + end = ExtendThroughContiguousBugsee(lines, end); return true; } } @@ -201,7 +291,7 @@ static bool TryFindPatchRegion(List lines, out int start, out int end) if (IsActiveBugseePluginLine(lines[i])) { start = i; - end = FindPatchEnd(lines, start); + end = FindContiguousPluginEnd(lines, start); return true; } } @@ -209,34 +299,215 @@ static bool TryFindPatchRegion(List lines, out int start, out int end) return false; } - static int FindPatchEnd(List lines, int start) + static int FindContiguousPluginEnd(List lines, int start) + { + return ExtendThroughContiguousBugsee(lines, start); + } + + static int ExtendThroughContiguousBugsee(List lines, int end) + { + int i = end + 1; + while (i < lines.Count && IsBlank(lines[i])) + { + i++; + } + + if (i >= lines.Count || !lines[i].TrimStart().StartsWith("bugsee {", StringComparison.Ordinal)) + { + return end; + } + + return FindBugseeBlockEnd(lines, i); + } + + static int FindBugseeBlockEnd(List lines, int bugseeStart) { - for (int i = start; i < lines.Count; i++) + int depth = 0; + for (int j = bugseeStart; j < lines.Count; j++) { - if (lines[i].IndexOf(NdkMarkerComment, StringComparison.Ordinal) >= 0) + depth += CountBraceDelta(lines[j]); + if (depth <= 0 && lines[j].IndexOf('}', StringComparison.Ordinal) >= 0) { - return i; + return j; } } - return start; + return bugseeStart; } - static bool IsActiveBugseePluginLine(string line) + static bool TryFindNdkRegion(List lines, out int start, out int end) { - if (line.IndexOf(BugseeGradlePluginId, StringComparison.Ordinal) < 0) + start = -1; + end = -1; + for (int i = 0; i < lines.Count; i++) { - return false; + if (lines[i].IndexOf(NdkMarkerComment, StringComparison.Ordinal) < 0) + { + continue; + } + + end = i; + start = i; + for (int j = i; j >= 0; j--) + { + if (lines[j].TrimStart().StartsWith("bugsee {", StringComparison.Ordinal)) + { + start = j; + break; + } + } + + return true; } - string trimmed = line.TrimStart(); - if (trimmed.StartsWith("//", StringComparison.Ordinal)) + for (int i = 0; i < lines.Count; i++) + { + if (!lines[i].TrimStart().StartsWith("bugsee {", StringComparison.Ordinal)) + { + continue; + } + + start = i; + end = FindBugseeBlockEnd(lines, i); + return true; + } + + return false; + } + + static bool TryFindPluginLineIndex(List lines, out int pluginIndex) + { + pluginIndex = -1; + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].IndexOf(MarkerComment, StringComparison.Ordinal) >= 0 || + IsActiveBugseePluginLine(lines[i])) + { + pluginIndex = i; + return true; + } + } + + return false; + } + + static int CountBraceDelta(string line) + { + int delta = 0; + bool inSingle = false; + bool inDouble = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (!inSingle && !inDouble && c == '/' && i + 1 < line.Length && line[i + 1] == '/') + { + break; + } + + if (c == '\'' && !inDouble) + { + inSingle = !inSingle; + continue; + } + + if (c == '"' && !inSingle) + { + inDouble = !inDouble; + continue; + } + + if (inSingle || inDouble) + { + continue; + } + + if (c == '{') + { + delta++; + } + else if (c == '}') + { + delta--; + } + } + + return delta; + } + + static bool LinesEqual(List lines, int start, int end, List replacement) + { + if (end - start + 1 != replacement.Count) { return false; } - return trimmed.IndexOf("id ", StringComparison.Ordinal) >= 0 || - trimmed.IndexOf("apply plugin", StringComparison.Ordinal) >= 0; + for (int i = 0; i < replacement.Count; i++) + { + if (!string.Equals(lines[start + i], replacement[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + static void ReplaceLines(List lines, int start, int end, List replacement) + { + int removeCount = end - start + 1; + lines.RemoveRange(start, removeCount); + lines.InsertRange(start, replacement); + } + + static string JoinLines(List lines, string newline, bool trailingNewline) + { + var sb = new StringBuilder(); + for (int i = 0; i < lines.Count; i++) + { + sb.Append(lines[i]); + if (i < lines.Count - 1) + { + sb.Append(newline); + } + else if (trailingNewline) + { + sb.Append(newline); + } + } + + return sb.ToString(); + } + + static bool IsBlank(string line) + { + return string.IsNullOrWhiteSpace(line); + } + + static void AppendMarkedLines(StringBuilder sb, string markedLine, string newline) + { + var markedLines = SplitMarkedLines(markedLine); + for (int m = 0; m < markedLines.Count; m++) + { + if (m > 0) + { + sb.Append(newline); + } + + sb.Append(markedLines[m]); + } + } + + static List SplitMarkedLines(string markedLine) + { + string normalized = NormalizeNewlines(markedLine); + var parts = normalized.Split('\n'); + var result = new List(parts.Length); + for (int i = 0; i < parts.Length; i++) + { + result.Add(parts[i]); + } + + return result; } static string InsertAfterLine( @@ -313,7 +584,7 @@ static int IndexOfOutsideStrings(string line, string token, int start) if (!inSingle && !inDouble && c == '/' && i + 1 < line.Length && line[i + 1] == '*') { - if (line.AsSpan(i).StartsWith(token)) + if (StartsWithAt(line, i, token)) { return i; } @@ -331,7 +602,7 @@ static int IndexOfOutsideStrings(string line, string token, int start) continue; } - if (!inSingle && !inDouble && line.AsSpan(i).StartsWith(token)) + if (!inSingle && !inDouble && StartsWithAt(line, i, token)) { return i; } @@ -340,6 +611,33 @@ static int IndexOfOutsideStrings(string line, string token, int start) return -1; } + static bool StartsWithAt(string line, int index, string token) + { + if (index + token.Length > line.Length) + { + return false; + } + + return string.Compare(line, index, token, 0, token.Length, StringComparison.Ordinal) == 0; + } + + static bool IsActiveBugseePluginLine(string line) + { + if (line.IndexOf(BugseeGradlePluginId, StringComparison.Ordinal) < 0) + { + return false; + } + + string trimmed = line.TrimStart(); + if (trimmed.StartsWith("//", StringComparison.Ordinal)) + { + return false; + } + + return trimmed.IndexOf("id ", StringComparison.Ordinal) >= 0 || + trimmed.IndexOf("apply plugin", StringComparison.Ordinal) >= 0; + } + static List SplitLines(string text, out string newline) { newline = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; diff --git a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs index 3224913..7fa0c0c 100644 --- a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs +++ b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs @@ -96,5 +96,38 @@ public void Plugin_only_launcher_patch_adds_ndk_block() string twice = MarkedGradleBlock.Apply(once, LauncherAnchor, LauncherPatch); Assert.That(twice, Is.EqualTo(once)); } + + [Test] + public void Split_ndk_block_does_not_delete_intervening_gradle() + { + const string split = + LauncherAnchor + "\n" + + "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + + "dependencies {\n implementation project(':unityLibrary')\n}\n\n" + + "android {\n compileSdkVersion 34\n}\n\n" + + "bugsee {\n ndk { enabled = true }\n} // bugsee:gradle-ndk\n"; + string twice = MarkedGradleBlock.Apply(split, LauncherAnchor, LauncherPatch); + Assert.That(twice, Does.Contain("dependencies {")); + Assert.That(twice, Does.Contain("android {")); + Assert.That(twice, Does.Contain(MarkedGradleBlock.NdkMarkerComment)); + Assert.That(MarkedGradleBlock.Apply(twice, LauncherAnchor, LauncherPatch), Is.EqualTo(twice)); + } + + [Test] + public void Legacy_launcher_with_unmarked_ndk_block_upgrades_without_duplicating_ndk() + { + const string legacyLauncher = + LauncherAnchor + "\n" + + "// Bugsee Gradle plugin\n" + + "apply plugin: 'com.bugsee.android.gradle'\n\n" + + "bugsee {\n ndk { enabled = true }\n}\n\n" + + "dependencies {\n implementation project(':unityLibrary')\n}\n"; + string once = MarkedGradleBlock.Apply(legacyLauncher, LauncherAnchor, LauncherPatch); + Assert.That(once, Does.Contain(MarkedGradleBlock.NdkMarkerComment)); + Assert.That(once.IndexOf("bugsee {", StringComparison.Ordinal), + Is.EqualTo(once.LastIndexOf("bugsee {", StringComparison.Ordinal))); + string twice = MarkedGradleBlock.Apply(once, LauncherAnchor, LauncherPatch); + Assert.That(twice, Is.EqualTo(once)); + } } } From 8df0e9f69f3991f641a55ac30030aa21c0ea7a0c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:22:50 +0500 Subject: [PATCH 024/157] Adopt player thread after Ensure succeeds only. Bootstrap always records the Unity player thread, and only RunSyncLifecycle drains the queue before inline lifecycle work so native callbacks do not re-enter Launch/Stop. Co-authored-by: Cursor --- Runtime/Internal/MainThreadDispatcher.cs | 26 +++++++----------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/Runtime/Internal/MainThreadDispatcher.cs b/Runtime/Internal/MainThreadDispatcher.cs index 2c397bd..53b9fbc 100644 --- a/Runtime/Internal/MainThreadDispatcher.cs +++ b/Runtime/Internal/MainThreadDispatcher.cs @@ -15,7 +15,7 @@ public sealed class MainThreadDispatcher : MonoBehaviour [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void Bootstrap() { - AdoptMainThreadId(); + _mainThreadId = Thread.CurrentThread.ManagedThreadId; Ensure(); } @@ -26,21 +26,13 @@ public static MainThreadDispatcher Ensure() return _instance; } - AdoptMainThreadId(); var go = new GameObject("Bugsee.MainThreadDispatcher"); DontDestroyOnLoad(go); _instance = go.AddComponent(); + _mainThreadId = Thread.CurrentThread.ManagedThreadId; return _instance; } - static void AdoptMainThreadId() - { - if (_mainThreadId == 0) - { - _mainThreadId = Thread.CurrentThread.ManagedThreadId; - } - } - public static bool IsMainThread => _mainThreadId != 0 && Thread.CurrentThread.ManagedThreadId == _mainThreadId; @@ -53,18 +45,12 @@ static void DrainQueueInline() } } - static void RunInlineOnMainThread(Action action) - { - DrainQueueInline(); - action(); - } - public static void Run(Action action) { if (action == null) return; if (IsMainThread) { - RunInlineOnMainThread(action); + action(); return; } @@ -85,7 +71,7 @@ public static bool RunSync(Action action, int timeoutMs = 5000) { try { - RunInlineOnMainThread(action); + action(); return true; } catch (Exception ex) @@ -120,6 +106,7 @@ public static bool RunSync(Action action, int timeoutMs = 5000) /// /// Like but skips the queued action if the wait times out /// (for iOS lifecycle calls that must not run after the caller has moved on). + /// Drains pending queue work on the player thread before running lifecycle. /// public static bool RunSyncLifecycle(Action action, int timeoutMs = 5000) { @@ -129,7 +116,8 @@ public static bool RunSyncLifecycle(Action action, int timeoutMs = 5000) { try { - RunInlineOnMainThread(action); + DrainQueueInline(); + action(); return true; } catch (Exception ex) From 60ff00974e762b12deb155946e5952c9cfd3153d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 11:32:26 +0500 Subject: [PATCH 025/157] Treat commented Gradle plugin lines as not satisfied for upsert. Co-authored-by: Cursor --- Runtime/WrapperPolicy/MarkedGradleBlock.cs | 38 ++++++++++++------- .../WrapperPolicy/MarkedGradleBlockTests.cs | 35 +++++++++++++++++ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs index 29cca4c..33146c7 100644 --- a/Runtime/WrapperPolicy/MarkedGradleBlock.cs +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -28,17 +28,13 @@ public static string Apply(string existing, string anchorLine, string markedLine throw new ArgumentNullException(nameof(markedLine)); } - if (ContainsNormalizedBlock(existing, markedLine)) - { - return existing; - } - SplitPayload(markedLine, out List pluginLines, out List ndkLines); if (IsPluginPayloadSatisfied(existing, pluginLines) && IsNdkPayloadSatisfied(existing, ndkLines)) { return existing; } + var lines = SplitLines(existing, out string newline); bool changed = false; @@ -95,11 +91,6 @@ public static string Apply(string existing, string anchorLine, string markedLine return InsertAfterLine(existing, lines, newline, anchorIndex, markedLine); } - static bool ContainsNormalizedBlock(string existing, string markedLine) - { - return NormalizeNewlines(existing).IndexOf(NormalizeNewlines(markedLine), StringComparison.Ordinal) >= 0; - } - static string NormalizeNewlines(string text) { return text.Replace("\r\n", "\n").Replace("\r", "\n"); @@ -107,6 +98,8 @@ static string NormalizeNewlines(string text) static bool IsPluginPayloadSatisfied(string existing, List pluginLines) { + var lines = SplitLines(existing, out _); + bool required = false; for (int i = 0; i < pluginLines.Count; i++) { if (IsBlank(pluginLines[i])) @@ -114,13 +107,24 @@ static bool IsPluginPayloadSatisfied(string existing, List pluginLines) continue; } - if (existing.IndexOf(pluginLines[i], StringComparison.Ordinal) < 0) + required = true; + bool found = false; + for (int j = 0; j < lines.Count; j++) + { + if (string.Equals(lines[j], pluginLines[i], StringComparison.Ordinal)) + { + found = true; + break; + } + } + + if (!found) { return false; } } - return pluginLines.Count > 0; + return required; } static bool IsNdkPayloadSatisfied(string existing, List ndkLines) @@ -130,7 +134,13 @@ static bool IsNdkPayloadSatisfied(string existing, List ndkLines) return true; } - return existing.IndexOf(NdkMarkerComment, StringComparison.Ordinal) >= 0; + var lines = SplitLines(existing, out _); + if (!TryFindNdkRegion(lines, out int start, out int end)) + { + return false; + } + + return LinesEqual(lines, start, end, ndkLines); } static void SplitPayload(string markedLine, out List pluginLines, out List ndkLines) @@ -326,7 +336,7 @@ static int FindBugseeBlockEnd(List lines, int bugseeStart) for (int j = bugseeStart; j < lines.Count; j++) { depth += CountBraceDelta(lines[j]); - if (depth <= 0 && lines[j].IndexOf('}', StringComparison.Ordinal) >= 0) + if (depth <= 0 && lines[j].IndexOf('}') >= 0) { return j; } diff --git a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs index 7fa0c0c..ea463f8 100644 --- a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs +++ b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs @@ -73,6 +73,41 @@ public void Commented_out_marker_is_replaced_with_active_patch() Assert.That(once, Does.Not.Contain("// apply plugin: 'com.bugsee.android.gradle'")); } + [Test] + public void Commented_plugin_with_ndk_marker_still_gets_restored() + { + const string commented = + LauncherAnchor + "\n" + + "// apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + + "bugsee {\n ndk { enabled = true }\n} // bugsee:gradle-ndk\n"; + string once = MarkedGradleBlock.Apply(commented, LauncherAnchor, LauncherPatch); + Assert.That(once, Does.Contain("\napply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin")); + Assert.That(once, Does.Not.Contain("// apply plugin: 'com.bugsee.android.gradle'")); + } + + [Test] + public void Commented_base_plugin_id_line_is_restored() + { + const string commented = + Anchor + "\n" + + "// id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin\n}\n"; + string once = MarkedGradleBlock.Apply(commented, Anchor, Marked); + Assert.That(once, Does.Contain(Marked)); + Assert.That(once, Does.Not.Contain("// id 'com.bugsee.android.gradle'")); + } + + [Test] + public void Disabled_ndk_in_marked_block_is_upserted_to_enabled() + { + const string disabledNdk = + LauncherAnchor + "\n" + + "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + + "bugsee {\n ndk { enabled = false }\n} // bugsee:gradle-ndk\n"; + string once = MarkedGradleBlock.Apply(disabledNdk, LauncherAnchor, LauncherPatch); + Assert.That(once, Does.Contain("enabled = true")); + Assert.That(once, Does.Not.Contain("enabled = false")); + } + [Test] public void Sample_launcher_gets_plugin_and_ndk_block_idempotently() { From 8dcb2f69d148d7af74bbd0e4e901d1dd027199ef Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 12:43:18 +0500 Subject: [PATCH 026/157] Send Unity Debug output through the wrapper channel as Custom. Co-authored-by: Cursor --- Plugins/Android/UnityWrapperProvider.java | 20 +++++++- Plugins/iOS/BugseeUnityCallbacks.mm | 20 ++++++++ Runtime/Internal/HostLogForwarder.cs | 49 ++++++++++++++++++ Runtime/Internal/HostLogForwarder.cs.meta | 11 ++++ Runtime/Platform/Android/AndroidBridge.cs | 13 +++++ Runtime/Platform/Editor/EditorBridge.cs | 1 + Runtime/Platform/IBugseeNativeBridge.cs | 3 ++ Runtime/Platform/IOS/IOSBridge.cs | 10 ++++ Tests~/WrapperPolicy/ChannelLogSourceTests.cs | 51 +++++++++++++++++++ 9 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 Runtime/Internal/HostLogForwarder.cs create mode 100644 Runtime/Internal/HostLogForwarder.cs.meta create mode 100644 Tests~/WrapperPolicy/ChannelLogSourceTests.cs diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index d57e644..f7326bd 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -27,6 +27,7 @@ final class UnityWrapper implements BugseeWrapper { private String scriptingBackend = "unknown"; private String productName = "unknown"; private String wrapperVersion = "unknown"; + private static volatile BugseeWrapperChannel channel; static void install() { if (installed) return; @@ -89,7 +90,24 @@ public void onLifecycleEvent(String eventType, Object data) { @Override public void onWrapperChannelAvailable(BugseeWrapperChannel value) { - // Task 9 stores the channel for host log/network submission. + channel = value; + } + + public static void channelLog(String message, int level, int source) { + BugseeWrapperChannel current = channel; + if (current == null || message == null) return; + com.bugsee.library.contracts.options.LogLevel nativeLevel; + switch (level) { + case 1: nativeLevel = com.bugsee.library.contracts.options.LogLevel.Error; break; + case 2: nativeLevel = com.bugsee.library.contracts.options.LogLevel.Warning; break; + case 4: nativeLevel = com.bugsee.library.contracts.options.LogLevel.Debug; break; + case 5: nativeLevel = com.bugsee.library.contracts.options.LogLevel.Verbose; break; + default: nativeLevel = com.bugsee.library.contracts.options.LogLevel.Info; break; + } + com.bugsee.library.contracts.internal.LogSource nativeSource = + com.bugsee.library.contracts.internal.LogSource.fromRawValue((byte) source, com.bugsee.library.contracts.internal.LogSource.Custom); + if (nativeSource == null) nativeSource = com.bugsee.library.contracts.internal.LogSource.Custom; + current.log(null, message, nativeLevel, nativeSource); } @Override diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 664a8f4..78d9983 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -42,6 +42,7 @@ @class BugseeUnityWrapper; static BugseeUnityWrapper *gWrapper; +static id gChannel; static void BugseeUnityEnsureState(void) { @@ -293,6 +294,11 @@ + (void)load [Bugsee setWrapper:gWrapper]; } +- (void)onWrapperChannelAvailable:(id)channel +{ + gChannel = channel; +} + - (NSString *)wrapperType { return @"unity"; } - (NSString *)wrapperVersion { return gWrapperVersion ?: @"unknown"; } - (NSString *)wrapperBuild { return gWrapperBuild ?: @"unknown"; } @@ -388,6 +394,19 @@ void _bugsee_register_unity_callbacks(BugseeUnityFilterCb filterCb, gLifecycleCb = lifecycleCb; } +void _bugsee_channel_log(const char *message, int level, int source) +{ + id channel = gChannel; + if (!channel || !message) return; + if (![channel respondsToSelector:@selector(logWithTag:message:level:source:)]) return; + NSString *text = [NSString stringWithUTF8String:message]; + if (!text) return; + [channel logWithTag:nil + message:text + level:(BugseeLogLevel)level + source:(BGSLogEventSource)source]; +} + void _bugsee_ensure_wrapper(const char *version, const char *build) { if (version) gWrapperVersion = [NSString stringWithUTF8String:version]; @@ -533,6 +552,7 @@ void _bugsee_complete_report(int64_t requestId, const char *resultJson) extern "C" { void _bugsee_register_unity_callbacks(void *a, void *b, void *c) { (void)a; (void)b; (void)c; } +void _bugsee_channel_log(const char *message, int level, int source) { (void)message; (void)level; (void)source; } void _bugsee_ensure_wrapper(const char *version, const char *build) { (void)version; (void)build; } void _bugsee_set_wrapper_context(const char *json) { (void)json; } void _bugsee_set_network_filter_enabled(int enabled) { (void)enabled; } diff --git a/Runtime/Internal/HostLogForwarder.cs b/Runtime/Internal/HostLogForwarder.cs new file mode 100644 index 0000000..b35d991 --- /dev/null +++ b/Runtime/Internal/HostLogForwarder.cs @@ -0,0 +1,49 @@ +using Bugsee.Contracts.Options; +using Bugsee.Platform; +using Bugsee.WrapperPolicy; +using UnityEngine; + +namespace Bugsee.Internal +{ + /// + /// Sends Unity Debug output through the wrapper channel as Custom. + /// Public Bugsee.Log stays on the SDK log API. + /// + public static class HostLogForwarder + { + static bool _installed; + + public static void InstallOnce(IBugseeNativeBridge bridge) + { + if (_installed || bridge == null || !bridge.IsSupported) + { + return; + } + + _installed = true; + Application.logMessageReceived += (message, stackTrace, type) => + { + if (type == LogType.Exception || string.IsNullOrEmpty(message)) + { + return; + } + + bridge.ChannelLog(message, LevelFor(type)); + }; + } + + static LogLevel LevelFor(LogType type) + { + switch (type) + { + case LogType.Error: + case LogType.Assert: + return LogLevel.Error; + case LogType.Warning: + return LogLevel.Warning; + default: + return LogLevel.Info; + } + } + } +} diff --git a/Runtime/Internal/HostLogForwarder.cs.meta b/Runtime/Internal/HostLogForwarder.cs.meta new file mode 100644 index 0000000..cf393ac --- /dev/null +++ b/Runtime/Internal/HostLogForwarder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f3c1a9e4b2d4f0a8c7e5d1b9a0e3c24 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 107caee..6376bc8 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -6,6 +6,7 @@ using Bugsee.Contracts.Feedback; using Bugsee.Contracts.Lifecycle; using Bugsee.Contracts.Options; +using Bugsee.WrapperPolicy; using Bugsee.Contracts.Reporting; using Bugsee; using Bugsee.Internal; @@ -73,6 +74,7 @@ public void Launch(string appToken, IDictionary options) _bugsee.CallStatic("launch", activity, appToken, map); } ExceptionPipeline.Install(this, options); + HostLogForwarder.InstallOnce(this); } public void Relaunch(IDictionary options) @@ -82,6 +84,7 @@ public void Relaunch(IDictionary options) _bugsee.CallStatic("relaunch", map); } ExceptionPipeline.Install(this, options); + HostLogForwarder.InstallOnce(this); } public void Stop(Action completion = null) @@ -112,6 +115,16 @@ public BugseeStatus GetStatus() public void EndBlackout() => _bugsee.CallStatic("endBlackout"); public bool IsBlackout() => _bugsee.CallStatic("isBlackout"); + public void ChannelLog(string message, LogLevel level) + { + if (string.IsNullOrEmpty(message)) return; + int source = WrapperLogSourcePolicy.Resolve(null); + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) + { + wrapper.CallStatic("channelLog", message, (int)level, source); + } + } + public void Log(string message, LogLevel level) { using (var clazz = new AndroidJavaClass("com.bugsee.library.contracts.options.LogLevel")) diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index f4d4433..2481a24 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -30,6 +30,7 @@ public void Relaunch(IDictionary options) { } public void EndBlackout() => _blackout = false; public bool IsBlackout() => _blackout; public void Log(string message, LogLevel level) { } + public void ChannelLog(string message, LogLevel level) { } public void Trace(string name, object value) { } public void Event(string name, IDictionary parameters) { } public void LogException(Exception exception, IDictionary options = null) { } diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index fecf096..049bda0 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -26,6 +26,9 @@ public interface IBugseeNativeBridge bool IsBlackout(); void Log(string message, LogLevel level); + + /// Submit a host log on the wrapper channel. Public stays on the SDK log API. + void ChannelLog(string message, LogLevel level); void Trace(string name, object value); void Event(string name, IDictionary parameters); void LogException(Exception exception, IDictionary options = null); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index c485c4e..81377e6 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -7,6 +7,7 @@ using Bugsee.Contracts.Feedback; using Bugsee.Contracts.Lifecycle; using Bugsee.Contracts.Options; +using Bugsee.WrapperPolicy; using Bugsee.Contracts.Reporting; using Bugsee.Internal; using Bugsee.Platform; @@ -41,6 +42,7 @@ public void Launch(string appToken, IDictionary options) _bugsee_launch(appToken, ToJsonObject(options)); _launched = true; ExceptionPipeline.Install(this, options); + HostLogForwarder.InstallOnce(this); })) { Debug.LogError("[Bugsee] Launch timed out waiting for the Unity main thread."); @@ -53,6 +55,7 @@ public void Relaunch(IDictionary options) { _bugsee_relaunch(ToJsonObject(options)); ExceptionPipeline.Install(this, options); + HostLogForwarder.InstallOnce(this); })) { Debug.LogError("[Bugsee] Relaunch timed out waiting for the Unity main thread."); @@ -92,6 +95,12 @@ public void EndBlackout() public bool IsBlackout() => _blackout; + public void ChannelLog(string message, LogLevel level) + { + if (string.IsNullOrEmpty(message)) return; + _bugsee_channel_log(message, (int)level, WrapperLogSourcePolicy.Resolve(null)); + } + public void Log(string message, LogLevel level) => _bugsee_log(message ?? "", (int)level); @@ -272,6 +281,7 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_start_blackout(); [DllImport("__Internal")] static extern void _bugsee_end_blackout(); [DllImport("__Internal")] static extern void _bugsee_log(string message, int level); + [DllImport("__Internal")] static extern void _bugsee_channel_log(string message, int level, int source); [DllImport("__Internal")] static extern void _bugsee_trace(string name, string valueJson); [DllImport("__Internal")] static extern void _bugsee_event(string name, string paramsJson); [DllImport("__Internal")] static extern void _bugsee_logException(string name, string reason, bool handled); diff --git a/Tests~/WrapperPolicy/ChannelLogSourceTests.cs b/Tests~/WrapperPolicy/ChannelLogSourceTests.cs new file mode 100644 index 0000000..8cd40e5 --- /dev/null +++ b/Tests~/WrapperPolicy/ChannelLogSourceTests.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class ChannelLogSourceTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + [Test] + public void Host_logs_use_the_channel_and_public_log_does_not() + { + string java = File.ReadAllText(RepoFile("Plugins/Android/UnityWrapperProvider.java")); + Assert.That(java, Does.Contain("channel = value")); + Assert.That(java, Does.Contain("channelLog")); + Assert.That(java, Does.Contain("current.log")); + + string forwarder = File.ReadAllText(RepoFile("Runtime/Internal/HostLogForwarder.cs")); + Assert.That(forwarder, Does.Contain("logMessageReceived")); + Assert.That(forwarder, Does.Not.Contain("WrapperLogSourcePolicy")); + + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(android, Does.Contain("WrapperLogSourcePolicy.Resolve(null)")); + Assert.That(android, Does.Contain("\"channelLog\"")); + Assert.That(android, Does.Contain("\"log\"")); + + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + Assert.That(ios, Does.Contain("WrapperLogSourcePolicy.Resolve(null)")); + Assert.That(ios, Does.Contain("_bugsee_channel_log")); + Assert.That(ios, Does.Contain("_bugsee_log")); + + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + Assert.That(callbacks, Does.Contain("onWrapperChannelAvailable:")); + Assert.That(callbacks, Does.Contain("gChannel = channel")); + Assert.That(callbacks, Does.Contain("logWithTag:nil")); + } + } +} From 2ae239c10aae42693dac4e20b4f46768188ca7e6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 12:49:58 +0500 Subject: [PATCH 027/157] Guard host log forwarding against filter reentrancy. Use logMessageReceivedThreaded so off-main Debug output reaches the wrapper channel without stack overflow when a log filter logs back. Co-authored-by: Cursor --- Runtime/Internal/HostLogForwarder.cs | 33 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Runtime/Internal/HostLogForwarder.cs b/Runtime/Internal/HostLogForwarder.cs index b35d991..ff01510 100644 --- a/Runtime/Internal/HostLogForwarder.cs +++ b/Runtime/Internal/HostLogForwarder.cs @@ -13,6 +13,9 @@ public static class HostLogForwarder { static bool _installed; + [ThreadStatic] + static int _forwardDepth; + public static void InstallOnce(IBugseeNativeBridge bridge) { if (_installed || bridge == null || !bridge.IsSupported) @@ -21,15 +24,33 @@ public static void InstallOnce(IBugseeNativeBridge bridge) } _installed = true; - Application.logMessageReceived += (message, stackTrace, type) => + Application.logMessageReceivedThreaded += (message, stackTrace, type) => { - if (type == LogType.Exception || string.IsNullOrEmpty(message)) - { - return; - } + ForwardLog(bridge, message, type); + }; + } + static void ForwardLog(IBugseeNativeBridge bridge, string message, LogType type) + { + if (type == LogType.Exception || string.IsNullOrEmpty(message)) + { + return; + } + + if (_forwardDepth > 0) + { + return; + } + + _forwardDepth++; + try + { bridge.ChannelLog(message, LevelFor(type)); - }; + } + finally + { + _forwardDepth--; + } } static LogLevel LevelFor(LogType type) From b84eff7d7dcea5b6b0ed7778c29a26ff576ff91a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 12:50:26 +0500 Subject: [PATCH 028/157] Run the wrapper policy tests on pull requests and on merges. Co-authored-by: Cursor --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7aed3a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: + - cursor/upm-package-scaffold + - main + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + wrapper-policy: + name: Wrapper policy tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Test + run: dotnet test Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj --nologo --configuration Release From 1f83351bd0cead6c415cc79da3509ca0679c561f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 12:54:54 +0500 Subject: [PATCH 029/157] Send host Debug logs to the wrapper channel on the main thread. Android JNI must run on an attached player thread; off-main logMessageReceivedThreaded callbacks hop through MainThreadDispatcher while keeping the reentrancy guard on the send. Co-authored-by: Cursor --- Runtime/Internal/HostLogForwarder.cs | 36 ++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/Runtime/Internal/HostLogForwarder.cs b/Runtime/Internal/HostLogForwarder.cs index ff01510..f9acb3e 100644 --- a/Runtime/Internal/HostLogForwarder.cs +++ b/Runtime/Internal/HostLogForwarder.cs @@ -1,6 +1,6 @@ +using System; using Bugsee.Contracts.Options; using Bugsee.Platform; -using Bugsee.WrapperPolicy; using UnityEngine; namespace Bugsee.Internal @@ -37,20 +37,36 @@ static void ForwardLog(IBugseeNativeBridge bridge, string message, LogType type) return; } - if (_forwardDepth > 0) + void Send() { - return; - } + if (_forwardDepth > 0) + { + return; + } - _forwardDepth++; - try - { - bridge.ChannelLog(message, LevelFor(type)); + _forwardDepth++; + try + { + bridge.ChannelLog(message, LevelFor(type)); + } + catch (Exception ex) + { + try { Debug.LogWarning($"Bugsee HostLogForwarder: {ex.Message}"); } + catch { /* ignore */ } + } + finally + { + _forwardDepth--; + } } - finally + + if (MainThreadDispatcher.IsMainThread) { - _forwardDepth--; + Send(); + return; } + + MainThreadDispatcher.Run(Send); } static LogLevel LevelFor(LogType type) From 48ba7df6cfd586bab047f59c4dad703931167551 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 13:04:00 +0500 Subject: [PATCH 030/157] Run Android and iOS bridge tests on pull requests and merges. The iOS bridge now calls the 7.0.0-beta5 trace and event selectors so that job can compile the pinned SDK. Co-authored-by: Cursor --- .github/workflows/ci.yml | 38 +++++++++ .gitignore | 5 ++ Plugins/iOS/BugseeUnityBridge.mm | 6 +- Plugins/iOS/BugseeUnityCallbacks.mm | 34 +++++++- Tests~/Android/build.gradle | 3 + Tests~/Android/gradle.properties | 2 + Tests~/Android/settings.gradle | 18 +++++ Tests~/Android/wrapper/build.gradle | 32 ++++++++ .../wrapper/src/main/AndroidManifest.xml | 2 + .../com/bugsee/unity/AndroidWrapperTests.java | 69 +++++++++++++++++ Tests~/iOS/BridgeTests.mm | 61 +++++++++++++++ Tests~/iOS/run-tests.sh | 77 +++++++++++++++++++ 12 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 Tests~/Android/build.gradle create mode 100644 Tests~/Android/gradle.properties create mode 100644 Tests~/Android/settings.gradle create mode 100644 Tests~/Android/wrapper/build.gradle create mode 100644 Tests~/Android/wrapper/src/main/AndroidManifest.xml create mode 100644 Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java create mode 100644 Tests~/iOS/BridgeTests.mm create mode 100644 Tests~/iOS/run-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aed3a4..8b5aed9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,41 @@ jobs: - name: Test run: dotnet test Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj --nologo --configuration Release + + android: + name: Android wrapper tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android platform + run: sdkmanager --install "platforms;android-35" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.11.1" + + - name: Test + working-directory: Tests~/Android + run: gradle :wrapper:testDebugUnitTest --no-daemon + + ios: + name: iOS bridge tests + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test + run: sh Tests~/iOS/run-tests.sh diff --git a/.gitignore b/.gitignore index 22ed22a..4a7c04c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ Native~/ios/Bugsee/Bugsee.xcframework/ # Local git worktrees for parallel task branches .worktrees/ +# Native bridge test build output +Tests~/**/build/ +Tests~/**/.build/ +Tests~/Android/local.properties + # Packaging *.unitypackage *.apk diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index b51d3c9..4319e4a 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -99,7 +99,7 @@ void _bugsee_trace(const char *name, const char *valueJson) if (!value) { value = BugseeNSString(valueJson); } - [Bugsee traceKey:BugseeNSString(name) withValue:value]; + [Bugsee trace:BugseeNSString(name) value:value]; } void _bugsee_event(const char *name, const char *paramsJson) @@ -107,11 +107,11 @@ void _bugsee_event(const char *name, const char *paramsJson) if (paramsJson) { id params = BugseeDeserializeJson(paramsJson); if ([params isKindOfClass:[NSDictionary class]]) { - [Bugsee registerEvent:BugseeNSString(name) withParams:params]; + [Bugsee event:BugseeNSString(name) params:params]; return; } } - [Bugsee registerEvent:BugseeNSString(name)]; + [Bugsee event:BugseeNSString(name)]; } void _bugsee_logException(const char *name, const char *reason, bool handled) diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 664a8f4..911671a 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -235,7 +235,9 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar id email = d[@"email"]; if ([email isKindOfClass:[NSString class]]) report.email = email; id sev = d[@"severity"]; - if ([sev respondsToSelector:@selector(integerValue)]) report.severity = [sev integerValue]; + if ([sev respondsToSelector:@selector(integerValue)]) { + report.severity = (BugseeSeverityLevel)[sev integerValue]; + } id labels = d[@"labels"]; if ([labels isKindOfClass:[NSArray class]]) { @@ -351,7 +353,11 @@ - (void)forwardReport:(id)report NSMutableDictionary *entry = [NSMutableDictionary dictionary]; entry[@"kind"] = @"report"; if (report) entry[@"report"] = report; - entry[@"completion"] = completion ? [completion copy] : ^{}; + if (completion) { + entry[@"completion"] = [completion copy]; + } else { + entry[@"completion"] = ^{}; + } gPending[@(requestId)] = entry; } NSString *json = BugseeUnityJsonString(BugseeUnityReportToDict(report)); @@ -527,6 +533,30 @@ void _bugsee_complete_report(int64_t requestId, const char *resultJson) if (completion) completion(); } +#if defined(BUGSEE_UNITY_TESTS) +const char *_bugsee_test_copy_wrapper_version(void) +{ + return gWrapperVersion ? strdup(gWrapperVersion.UTF8String) : NULL; +} + +const char *_bugsee_test_copy_wrapper_build(void) +{ + return gWrapperBuild ? strdup(gWrapperBuild.UTF8String) : NULL; +} + +int _bugsee_test_wrapper_installed(void) +{ + return gWrapper != nil; +} + +const char *_bugsee_test_copy_context_value(const char *key) +{ + if (!gWrapperContext || !key) return NULL; + NSString *value = gWrapperContext[[NSString stringWithUTF8String:key]]; + return value ? strdup(value.UTF8String) : NULL; +} +#endif + } // extern "C" #else diff --git a/Tests~/Android/build.gradle b/Tests~/Android/build.gradle new file mode 100644 index 0000000..3a9e2a0 --- /dev/null +++ b/Tests~/Android/build.gradle @@ -0,0 +1,3 @@ +plugins { + id "com.android.library" version "8.7.3" apply false +} diff --git a/Tests~/Android/gradle.properties b/Tests~/Android/gradle.properties new file mode 100644 index 0000000..344ead5 --- /dev/null +++ b/Tests~/Android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 diff --git a/Tests~/Android/settings.gradle b/Tests~/Android/settings.gradle new file mode 100644 index 0000000..a29508f --- /dev/null +++ b/Tests~/Android/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "bugsee-unity-android" +include ":wrapper" diff --git a/Tests~/Android/wrapper/build.gradle b/Tests~/Android/wrapper/build.gradle new file mode 100644 index 0000000..57e1432 --- /dev/null +++ b/Tests~/Android/wrapper/build.gradle @@ -0,0 +1,32 @@ +plugins { + id "com.android.library" +} + +android { + namespace "com.bugsee.unity.test" + compileSdk 35 + + defaultConfig { + minSdk 21 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + testOptions { + unitTests.returnDefaultValues = true + } + + sourceSets { + main { + java.srcDirs = ["../../../Plugins/Android"] + } + } +} + +dependencies { + implementation "com.bugsee:bugsee-android:7.3.0" + testImplementation "junit:junit:4.13.2" +} diff --git a/Tests~/Android/wrapper/src/main/AndroidManifest.xml b/Tests~/Android/wrapper/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/Tests~/Android/wrapper/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java new file mode 100644 index 0000000..a603725 --- /dev/null +++ b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java @@ -0,0 +1,69 @@ +package com.bugsee.unity; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; +import org.junit.Test; + +public class AndroidWrapperTests { + @Test + public void managedExceptionKeepsReasonJson() { + assertEquals("{\"signature\":\"abc\"}", new UnityManagedException("{\"signature\":\"abc\"}").getMessage()); + } + + @Test + public void managedExceptionNullReasonBecomesEmpty() { + assertEquals("", new UnityManagedException(null).getMessage()); + } + + @Test + public void manifestRegistersTheProviderBeforeLaunch() throws Exception { + String manifest = new String( + Files.readAllBytes(repoFile("Plugins/Android/AndroidManifest.xml")), + StandardCharsets.UTF_8); + assertTrue(manifest.contains("com.bugsee.unity.UnityWrapperProvider")); + assertTrue(manifest.contains("android:directBootAware=\"true\"")); + assertTrue(manifest.contains("android:initOrder=\"200\"")); + } + + @Test + public void compiledWrapperDeclaresTheSdkContract() throws Exception { + String pool = classPool("com/bugsee/unity/UnityWrapper.class"); + assertTrue(pool.contains("refineContext")); + assertTrue(pool.contains("getWrapperVersion")); + assertTrue(pool.contains("getWrapperBuild")); + assertTrue(pool.contains("getContext")); + assertTrue(pool.contains("onWrapperChannelAvailable")); + assertTrue(pool.contains("getSecureRectangles")); + assertTrue(classPool("com/bugsee/unity/UnityWrapperProvider.class").contains("UnityWrapperProvider")); + } + + static String classPool(String relative) throws Exception { + byte[] bytes = Files.readAllBytes(findUnderBuild(relative)); + return new String(bytes, StandardCharsets.ISO_8859_1); + } + + static Path findUnderBuild(String relative) throws Exception { + Path tail = Paths.get(relative); + try (Stream walk = Files.walk(Paths.get("build"))) { + return walk.filter(path -> path.endsWith(tail)).findFirst() + .orElseThrow(() -> new AssertionError("missing " + relative)); + } + } + + static Path repoFile(String relative) { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.exists(dir.resolve("package.json"))) { + dir = dir.getParent(); + } + if (dir == null) { + throw new AssertionError("Could not find repo root (package.json)"); + } + return dir.resolve(relative); + } +} diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm new file mode 100644 index 0000000..b6330a6 --- /dev/null +++ b/Tests~/iOS/BridgeTests.mm @@ -0,0 +1,61 @@ +#include +#include +#include + +extern "C" { +void _bugsee_set_wrapper_context(const char *json); +void _bugsee_ensure_wrapper(const char *version, const char *build); +const char *_bugsee_test_copy_wrapper_version(void); +const char *_bugsee_test_copy_wrapper_build(void); +int _bugsee_test_wrapper_installed(void); +const char *_bugsee_test_copy_context_value(const char *key); +} + +static void expect(int condition, const char *message) +{ + if (!condition) { + fprintf(stderr, "FAIL %s\n", message); + exit(1); + } + printf("ok %s\n", message); +} + +static int same(const char *value, const char *expected) +{ + if (value == NULL || expected == NULL) { + return value == expected; + } + return strcmp(value, expected) == 0; +} + +int main(void) +{ + const char *value; + + expect(_bugsee_test_wrapper_installed() == 1, "load registers one wrapper"); + + _bugsee_set_wrapper_context("{\"unity_version\":\"2021.3\",\"count\":1}"); + value = _bugsee_test_copy_context_value("unity_version"); + expect(same(value, "2021.3"), "context keeps string values"); + free((void *)value); + expect(_bugsee_test_copy_context_value("count") == NULL, "context drops non-strings"); + + _bugsee_set_wrapper_context("not-json"); + value = _bugsee_test_copy_context_value("unity_version"); + expect(same(value, "2021.3"), "invalid json keeps the previous context"); + free((void *)value); + + _bugsee_set_wrapper_context(NULL); + expect(_bugsee_test_copy_context_value("unity_version") == NULL, "null json clears context"); + + _bugsee_ensure_wrapper("9.9.9", "ci"); + value = _bugsee_test_copy_wrapper_version(); + expect(same(value, "9.9.9"), "ensure wrapper stores the version"); + free((void *)value); + value = _bugsee_test_copy_wrapper_build(); + expect(same(value, "ci"), "ensure wrapper stores the build"); + free((void *)value); + + printf("ios bridge tests passed\n"); + return 0; +} diff --git a/Tests~/iOS/run-tests.sh b/Tests~/iOS/run-tests.sh new file mode 100644 index 0000000..c90bc8b --- /dev/null +++ b/Tests~/iOS/run-tests.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)" +BUILD="$ROOT/Tests~/iOS/.build" +ZIP="$BUILD/Bugsee-7.0.0-beta5.zip" +URL="https://download.bugsee.com/sdk/ios/spm/Bugsee-7.0.0-beta5.zip" +CHECKSUM="af8f9670fddd3f94d7ee7d7fe2b3696c2d9f46ad49e5e523758a10e29f6f8123" + +mkdir -p "$BUILD" +if [ ! -f "$ZIP" ] || ! printf '%s %s\n' "$CHECKSUM" "$ZIP" | shasum -a 256 -c - >/dev/null 2>&1; then + curl -fsSL "$URL" -o "$ZIP" + printf '%s %s\n' "$CHECKSUM" "$ZIP" | shasum -a 256 -c - +fi + +rm -rf "$BUILD/sdk" +mkdir -p "$BUILD/sdk" +unzip -q "$ZIP" -d "$BUILD/sdk" +FRAMEWORK="$(find "$BUILD/sdk" -type d -name Bugsee.xcframework | head -n 1)" +if [ -z "$FRAMEWORK" ]; then + echo "Bugsee.xcframework was not in the SPM zip" >&2 + exit 1 +fi + +case "$(uname -m)" in + arm64) TARGET="arm64-apple-ios15.0-simulator" ;; + *) TARGET="x86_64-apple-ios15.0-simulator" ;; +esac + +SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)" +SLICE="$FRAMEWORK/ios-arm64_x86_64-simulator" +BINARY="$BUILD/bridge-tests" + +clang -x objective-c++ -fobjc-arc -std=c++17 \ + -target "$TARGET" \ + -isysroot "$SDK" \ + -mios-simulator-version-min=15.0 \ + -DBUGSEE_UNITY_TESTS=1 \ + -F "$SLICE" \ + -framework Bugsee \ + -framework Foundation \ + -framework UIKit \ + -framework CoreGraphics \ + -framework QuartzCore \ + -framework SystemConfiguration \ + -framework Security \ + -framework CFNetwork \ + -lc++ \ + -ObjC \ + "$ROOT/Plugins/iOS/BugseeUnityCallbacks.mm" \ + "$ROOT/Plugins/iOS/BugseeUnityBridge.mm" \ + "$ROOT/Tests~/iOS/BridgeTests.mm" \ + -o "$BINARY" + +rm -rf "$BUILD/Bugsee.framework" +cp -R "$SLICE/Bugsee.framework" "$BUILD/Bugsee.framework" +install_name_tool -add_rpath @executable_path "$BINARY" +codesign --force --sign - "$BUILD/Bugsee.framework" >/dev/null +codesign --force --sign - "$BINARY" >/dev/null + +UDID="$(python3 - <<'PY' +import json, subprocess, sys +data = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"])) +for runtime, devices in data["devices"].items(): + if "iOS" not in runtime: + continue + for device in devices: + if device.get("isAvailable") and device["name"].startswith("iPhone"): + print(device["udid"]) + sys.exit(0) +sys.exit("No available iPhone simulator") +PY +)" + +xcrun simctl boot "$UDID" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$UDID" -b >/dev/null +xcrun simctl spawn "$UDID" "$BINARY" From a777e1503e81a85f17888b6490be2dab753bcdb7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 13:06:29 +0500 Subject: [PATCH 031/157] Keep channelLog for R8 and tear down the host log forwarder on Stop. ProGuard retains JNI channelLog, Stop clears wrapper channels and unsubscribes HostLogForwarder so post-stop Debug output does not hit native code. Co-authored-by: Cursor --- Plugins/Android/UnityWrapperProvider.java | 4 ++++ Plugins/Android/bugsee-unity-wrapper.proguard | 2 ++ Plugins/iOS/BugseeUnityCallbacks.mm | 6 +++++ Runtime/Internal/HostLogForwarder.cs | 24 ++++++++++++++++--- Runtime/Platform/Android/AndroidBridge.cs | 5 ++++ Runtime/Platform/IOS/IOSBridge.cs | 3 +++ Tests~/WrapperPolicy/ChannelLogSourceTests.cs | 20 ++++++++++++++++ 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index f7326bd..2da63d6 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -93,6 +93,10 @@ public void onWrapperChannelAvailable(BugseeWrapperChannel value) { channel = value; } + public static void clearWrapperChannel() { + channel = null; + } + public static void channelLog(String message, int level, int source) { BugseeWrapperChannel current = channel; if (current == null || message == null) return; diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard index a5e91fe..5346cf2 100644 --- a/Plugins/Android/bugsee-unity-wrapper.proguard +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -1,5 +1,7 @@ # C# calls com.bugsee.unity.UnityWrapper.refineContext via JNI; R8 cannot trace it. -keep class com.bugsee.unity.UnityWrapper { static void refineContext(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String); + static void channelLog(java.lang.String, int, int); + static void clearWrapperChannel(); } -keep class com.bugsee.unity.UnityWrapperProvider { *; } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 78d9983..35f132c 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -394,6 +394,11 @@ void _bugsee_register_unity_callbacks(BugseeUnityFilterCb filterCb, gLifecycleCb = lifecycleCb; } +void _bugsee_clear_wrapper_channel(void) +{ + gChannel = nil; +} + void _bugsee_channel_log(const char *message, int level, int source) { id channel = gChannel; @@ -552,6 +557,7 @@ void _bugsee_complete_report(int64_t requestId, const char *resultJson) extern "C" { void _bugsee_register_unity_callbacks(void *a, void *b, void *c) { (void)a; (void)b; (void)c; } +void _bugsee_clear_wrapper_channel(void) {} void _bugsee_channel_log(const char *message, int level, int source) { (void)message; (void)level; (void)source; } void _bugsee_ensure_wrapper(const char *version, const char *build) { (void)version; (void)build; } void _bugsee_set_wrapper_context(const char *json) { (void)json; } diff --git a/Runtime/Internal/HostLogForwarder.cs b/Runtime/Internal/HostLogForwarder.cs index f9acb3e..f3eb541 100644 --- a/Runtime/Internal/HostLogForwarder.cs +++ b/Runtime/Internal/HostLogForwarder.cs @@ -12,6 +12,8 @@ namespace Bugsee.Internal public static class HostLogForwarder { static bool _installed; + static IBugseeNativeBridge _bridge; + static Application.LogCallback _logHandler; [ThreadStatic] static int _forwardDepth; @@ -24,10 +26,26 @@ public static void InstallOnce(IBugseeNativeBridge bridge) } _installed = true; - Application.logMessageReceivedThreaded += (message, stackTrace, type) => + _bridge = bridge; + _logHandler = (message, stackTrace, type) => ForwardLog(_bridge, message, type); + Application.logMessageReceivedThreaded += _logHandler; + } + + public static void Uninstall() + { + if (!_installed) { - ForwardLog(bridge, message, type); - }; + return; + } + + if (_logHandler != null) + { + Application.logMessageReceivedThreaded -= _logHandler; + _logHandler = null; + } + + _bridge = null; + _installed = false; } static void ForwardLog(IBugseeNativeBridge bridge, string message, LogType type) diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 6376bc8..f7c27d4 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -89,6 +89,11 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { + HostLogForwarder.Uninstall(); + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) + { + wrapper.CallStatic("clearWrapperChannel"); + } ExceptionPipeline.Uninstall(); if (completion == null) { diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 81377e6..14217c8 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -66,7 +66,9 @@ public void Stop(Action completion = null) { if (!MainThreadDispatcher.RunSyncLifecycle(() => { + HostLogForwarder.Uninstall(); ExceptionPipeline.Uninstall(); + _bugsee_clear_wrapper_channel(); _bugsee_stop(); _launched = false; completion?.Invoke(); @@ -277,6 +279,7 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_launch(string appToken, string optionsJson); [DllImport("__Internal")] static extern void _bugsee_relaunch(string optionsJson); [DllImport("__Internal")] static extern void _bugsee_stop(); + [DllImport("__Internal")] static extern void _bugsee_clear_wrapper_channel(); [DllImport("__Internal")] static extern bool _bugsee_get_launched(); [DllImport("__Internal")] static extern void _bugsee_start_blackout(); [DllImport("__Internal")] static extern void _bugsee_end_blackout(); diff --git a/Tests~/WrapperPolicy/ChannelLogSourceTests.cs b/Tests~/WrapperPolicy/ChannelLogSourceTests.cs index 8cd40e5..d70c4f4 100644 --- a/Tests~/WrapperPolicy/ChannelLogSourceTests.cs +++ b/Tests~/WrapperPolicy/ChannelLogSourceTests.cs @@ -47,5 +47,25 @@ public void Host_logs_use_the_channel_and_public_log_does_not() Assert.That(callbacks, Does.Contain("gChannel = channel")); Assert.That(callbacks, Does.Contain("logWithTag:nil")); } + + [Test] + public void Proguard_keeps_jni_channelLog_entry_point() + { + string proguard = File.ReadAllText(RepoFile("Plugins/Android/bugsee-unity-wrapper.proguard")); + Assert.That(proguard, Does.Contain("channelLog(java.lang.String, int, int)")); + } + + [Test] + public void Stop_uninstalls_host_log_forwarder() + { + string forwarder = File.ReadAllText(RepoFile("Runtime/Internal/HostLogForwarder.cs")); + Assert.That(forwarder, Does.Contain("Uninstall")); + + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(android, Does.Contain("HostLogForwarder.Uninstall")); + + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + Assert.That(ios, Does.Contain("HostLogForwarder.Uninstall")); + } } } From bb7b8f728080d861954adcbd1d1e5ca3ee1c54eb Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 13:11:14 +0500 Subject: [PATCH 032/157] Use the runner Android SDK for the wrapper tests. Co-authored-by: Cursor --- .github/workflows/ci.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b5aed9..4225d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,22 +38,24 @@ jobs: uses: actions/checkout@v4 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android platform - run: sdkmanager --install "platforms;android-35" - - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: gradle-version: "8.11.1" + - name: Install Android platform + run: | + sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + manager="$(find "$sdk/cmdline-tools" -type f -name sdkmanager | head -n 1)" + set +o pipefail + yes | "$manager" --licenses >/dev/null || true + "$manager" --install "platforms;android-35" "build-tools;35.0.0" + - name: Test working-directory: Tests~/Android run: gradle :wrapper:testDebugUnitTest --no-daemon From 135c8854ec60f75e15bf5552fb5744fbb3d74098 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 13:20:19 +0500 Subject: [PATCH 033/157] Drop stale queued host logs and include error stack traces. Forwarder generations invalidate MainThreadDispatcher callbacks after Stop, and Error/Assert payloads append Unity stackTrace before channel submit. Co-authored-by: Cursor --- Runtime/Internal/HostLogForwarder.cs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Runtime/Internal/HostLogForwarder.cs b/Runtime/Internal/HostLogForwarder.cs index f3eb541..9ddff32 100644 --- a/Runtime/Internal/HostLogForwarder.cs +++ b/Runtime/Internal/HostLogForwarder.cs @@ -14,6 +14,7 @@ public static class HostLogForwarder static bool _installed; static IBugseeNativeBridge _bridge; static Application.LogCallback _logHandler; + static int _generation; [ThreadStatic] static int _forwardDepth; @@ -27,7 +28,8 @@ public static void InstallOnce(IBugseeNativeBridge bridge) _installed = true; _bridge = bridge; - _logHandler = (message, stackTrace, type) => ForwardLog(_bridge, message, type); + _generation++; + _logHandler = (message, stackTrace, type) => ForwardLog(_bridge, message, stackTrace, type); Application.logMessageReceivedThreaded += _logHandler; } @@ -38,6 +40,8 @@ public static void Uninstall() return; } + _generation++; + if (_logHandler != null) { Application.logMessageReceivedThreaded -= _logHandler; @@ -48,15 +52,28 @@ public static void Uninstall() _installed = false; } - static void ForwardLog(IBugseeNativeBridge bridge, string message, LogType type) + static void ForwardLog(IBugseeNativeBridge bridge, string message, string stackTrace, LogType type) { if (type == LogType.Exception || string.IsNullOrEmpty(message)) { return; } + var payload = message; + if (!string.IsNullOrEmpty(stackTrace)) + { + payload = message + "\n" + stackTrace; + } + + var generation = _generation; + void Send() { + if (generation != _generation) + { + return; + } + if (_forwardDepth > 0) { return; @@ -65,7 +82,7 @@ void Send() _forwardDepth++; try { - bridge.ChannelLog(message, LevelFor(type)); + bridge.ChannelLog(payload, LevelFor(type)); } catch (Exception ex) { From f4a6e8bd3e7cd6c011b520ef348f28db8b5c1963 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:02:35 +0500 Subject: [PATCH 034/157] Submit Android network events on the wrapper channel. Co-authored-by: Cursor --- Plugins/Android/UnityWrapperProvider.java | 6 ++ Plugins/Android/bugsee-unity-wrapper.proguard | 1 + .../ChannelNetworkSourceTests.cs | 72 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index 2da63d6..6b68504 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -114,6 +114,12 @@ public static void channelLog(String message, int level, int source) { current.log(null, message, nativeLevel, nativeSource); } + public static void channelAddNetwork(com.bugsee.library.contracts.exchange.NetworkEvent event) { + com.bugsee.library.contracts.internal.BugseeWrapperChannel current = channel; + if (current == null || event == null) return; + current.addNetworkEvent(event, true); + } + @Override public int[] getSecureRectangles(int display) { return new int[] { 1, 0 }; diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard index 5346cf2..2ebfd87 100644 --- a/Plugins/Android/bugsee-unity-wrapper.proguard +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -2,6 +2,7 @@ -keep class com.bugsee.unity.UnityWrapper { static void refineContext(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String); static void channelLog(java.lang.String, int, int); + static void channelAddNetwork(com.bugsee.library.contracts.exchange.NetworkEvent); static void clearWrapperChannel(); } -keep class com.bugsee.unity.UnityWrapperProvider { *; } diff --git a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs new file mode 100644 index 0000000..4db60f7 --- /dev/null +++ b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class ChannelNetworkSourceTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string ChannelAddNetworkMethodBody(string java) + { + const string marker = "public static void channelAddNetwork"; + int start = java.IndexOf(marker, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "channelAddNetwork method missing"); + int brace = java.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < java.Length; i++) + { + if (java[i] == '{') depth++; + else if (java[i] == '}') + { + depth--; + if (depth == 0) + return java.Substring(start, i - start + 1); + } + } + + throw new InvalidOperationException("Could not extract channelAddNetwork method body."); + } + + [Test] + public void Network_events_use_the_channel_not_public_addNetworkEvent() + { + string java = File.ReadAllText(RepoFile("Plugins/Android/UnityWrapperProvider.java")); + string body = ChannelAddNetworkMethodBody(java); + + Assert.That(body, Does.Contain("channelAddNetwork")); + Assert.That(body, Does.Contain("addNetworkEvent")); + Assert.That(body, Does.Contain("addNetworkEvent(event, true)")); + Assert.That(body, Does.Not.Contain("Bugsee.addNetworkEvent")); + + Assert.That(body, Does.Match(new Regex(@"current\s*=\s*channel"))); + Assert.That(body, Does.Match(new Regex(@"if\s*\(\s*current\s*==\s*null\s*\|\|\s*event\s*==\s*null\s*\)\s*return"))); + Assert.That(body, Does.Match(new Regex(@"current\.addNetworkEvent\s*\(\s*event\s*,\s*true\s*\)"))); + + Assert.That(java, Does.Contain("channelLog")); + Assert.That(body, Does.Not.Contain("channelLog")); + } + + [Test] + public void Proguard_keeps_jni_channelAddNetwork_entry_point() + { + string proguard = File.ReadAllText(RepoFile("Plugins/Android/bugsee-unity-wrapper.proguard")); + Assert.That(proguard, Does.Contain("channelAddNetwork(com.bugsee.library.contracts.exchange.NetworkEvent)")); + } + } +} From 06287571f704dfaaa0a889851086d824a8ac7018 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:03:21 +0500 Subject: [PATCH 035/157] Stop forwarding Android-only launch keys to iOS. Co-authored-by: Cursor --- .gitignore | 2 + Runtime/Platform/IOS/IOSBridge.cs | 4 +- Runtime/WrapperPolicy/OptionPlatformGate.cs | 50 +++++ .../WrapperPolicy/OptionPlatformGate.cs.meta | 11 ++ .../WrapperPolicy/OptionPlatformGateTests.cs | 178 ++++++++++++++++++ 5 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 Runtime/WrapperPolicy/OptionPlatformGate.cs create mode 100644 Runtime/WrapperPolicy/OptionPlatformGate.cs.meta create mode 100644 Tests~/WrapperPolicy/OptionPlatformGateTests.cs diff --git a/.gitignore b/.gitignore index 4a7c04c..3e26654 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ Native~/ios/Bugsee/Bugsee.xcframework/ # Native bridge test build output Tests~/**/build/ Tests~/**/.build/ +Tests~/**/bin/ +Tests~/**/obj/ Tests~/Android/local.properties # Packaging diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 14217c8..bddc974 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -39,7 +39,7 @@ public void Launch(string appToken, IDictionary options) if (!MainThreadDispatcher.RunSyncLifecycle(() => { EnsureWrapperRegistered(); - _bugsee_launch(appToken, ToJsonObject(options)); + _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options))); _launched = true; ExceptionPipeline.Install(this, options); HostLogForwarder.InstallOnce(this); @@ -53,7 +53,7 @@ public void Relaunch(IDictionary options) { if (!MainThreadDispatcher.RunSyncLifecycle(() => { - _bugsee_relaunch(ToJsonObject(options)); + _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options))); ExceptionPipeline.Install(this, options); HostLogForwarder.InstallOnce(this); })) diff --git a/Runtime/WrapperPolicy/OptionPlatformGate.cs b/Runtime/WrapperPolicy/OptionPlatformGate.cs new file mode 100644 index 0000000..2aa79da --- /dev/null +++ b/Runtime/WrapperPolicy/OptionPlatformGate.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using Bugsee.Contracts.Options; + +namespace Bugsee.WrapperPolicy +{ + public static class OptionPlatformGate + { + static readonly HashSet AndroidOnlyKeys = BuildAndroidOnlyKeys(); + + public static readonly IReadOnlyCollection AndroidOnly = AndroidOnlyKeys; + + static HashSet BuildAndroidOnlyKeys() + { + return new HashSet + { + Options.DetectAndReportExit, + Options.DetectAndReportExitLowMemory, + Options.DetectAndReportExitNotResponding, + Options.DetectAndReportExitNotRespondingAsCrash, + Options.DetectAndReportExitExcessiveResourceUsage, + Options.DetectAndReportExitDependencyDied, + Options.DetectAndReportExitUserRequested, + Options.DetectAndReportExitUserWasStopped, + Options.DetectAndReportExitPermissionChanged, + Options.DetectAndReportExitPackageUpdated, + Options.DetectAndReportExitPackageStateChanged, + Options.DetectAndReportExitOther, + Options.DetectAndReportExitUnknown, + Options.ReportingTriggerByNotification, + Options.ReportingTriggerByBroadcast, + }; + } + + public static IDictionary ForIos(IDictionary options) + { + if (options == null || options.Count == 0) + return new Dictionary(); + + var result = new Dictionary(options.Count); + foreach (var kv in options) + { + if (kv.Key == null || AndroidOnlyKeys.Contains(kv.Key)) + continue; + result[kv.Key] = kv.Value; + } + + return result; + } + } +} diff --git a/Runtime/WrapperPolicy/OptionPlatformGate.cs.meta b/Runtime/WrapperPolicy/OptionPlatformGate.cs.meta new file mode 100644 index 0000000..39dff36 --- /dev/null +++ b/Runtime/WrapperPolicy/OptionPlatformGate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c5506177c4545adb6ad5a7ebc2dfba9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs new file mode 100644 index 0000000..6f38a3a --- /dev/null +++ b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Bugsee.Contracts.Options; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class OptionPlatformGateTests + { + const string ForIosJsonCall = "ToJsonObject(OptionPlatformGate.ForIos(options))"; + + static string FindRepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return dir; + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string RepoFile(string relative) + { + return Path.GetFullPath(Path.Combine(FindRepoRoot(), relative)); + } + + static HashSet ExpectedAndroidOnlyKeysFromOptionsContract() + { + var expected = new HashSet(); + const BindingFlags flags = BindingFlags.Public | BindingFlags.Static; + foreach (var field in typeof(Options).GetFields(flags)) + { + if (field.FieldType != typeof(string)) + continue; + var name = field.Name; + if (name.StartsWith("DetectAndReportExit", StringComparison.Ordinal)) + expected.Add((string)field.GetValue(null)); + } + + expected.Add(Options.ReportingTriggerByNotification); + expected.Add(Options.ReportingTriggerByBroadcast); + return expected; + } + + [Test] + public void Ios_launch_drops_android_exit_and_trigger_keys() + { + var options = new Dictionary + { + [Options.CaptureVideo] = true, + [Options.DetectAndReportExit] = true, + [Options.ReportingTriggerByNotification] = true, + [Options.Endpoint] = "https://example.test" + }; + + var ios = OptionPlatformGate.ForIos(options); + + Assert.That(ios.ContainsKey(Options.CaptureVideo), Is.True); + Assert.That(ios.ContainsKey(Options.Endpoint), Is.True); + Assert.That(ios.ContainsKey(Options.DetectAndReportExit), Is.False); + Assert.That(ios.ContainsKey(Options.ReportingTriggerByNotification), Is.False); + } + + [Test] + public void Null_input_returns_empty_dictionary() + { + var ios = OptionPlatformGate.ForIos(null); + Assert.That(ios, Is.Not.Null); + Assert.That(ios.Count, Is.EqualTo(0)); + } + + [Test] + public void Empty_input_returns_empty_dictionary() + { + var ios = OptionPlatformGate.ForIos(new Dictionary()); + Assert.That(ios.Count, Is.EqualTo(0)); + } + + [Test] + public void Every_android_only_constant_is_dropped() + { + var expected = ExpectedAndroidOnlyKeysFromOptionsContract(); + Assert.That(OptionPlatformGate.AndroidOnly, Is.EquivalentTo(expected)); + + foreach (var key in expected) + { + var options = new Dictionary + { + [key] = true, + [Options.CaptureVideo] = false + }; + + var ios = OptionPlatformGate.ForIos(options); + + Assert.That(ios.ContainsKey(key), Is.False, "expected Android-only key to be removed: " + key); + Assert.That(ios.ContainsKey(Options.CaptureVideo), Is.True); + Assert.That(ios[Options.CaptureVideo], Is.EqualTo(false)); + } + } + + [Test] + public void Debug_and_unknown_keys_pass_through_with_copied_values() + { + var sentinel = new object(); + var options = new Dictionary + { + [Options.Debug] = true, + ["com.bugsee.option.custom.unknown"] = sentinel + }; + + var ios = OptionPlatformGate.ForIos(options); + + Assert.That(ios[Options.Debug], Is.EqualTo(true)); + Assert.That(ios["com.bugsee.option.custom.unknown"], Is.SameAs(sentinel)); + } + + [Test] + public void Input_dictionary_is_not_mutated() + { + var options = new Dictionary + { + [Options.DetectAndReportExit] = true, + [Options.ReportingTriggerByBroadcast] = false, + [Options.Endpoint] = "https://example.test" + }; + + var copyCount = options.Count; + _ = OptionPlatformGate.ForIos(options); + + Assert.That(options.Count, Is.EqualTo(copyCount)); + Assert.That(options.ContainsKey(Options.DetectAndReportExit), Is.True); + Assert.That(options.ContainsKey(Options.ReportingTriggerByBroadcast), Is.True); + } + + [Test] + public void IosBridge_launch_and_relaunch_call_ForIos_before_json() + { + string text = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + Assert.That( + CountOccurrences(text, "_bugsee_launch(appToken, " + ForIosJsonCall + ")"), + Is.EqualTo(1), + "Launch must JSON-encode OptionPlatformGate.ForIos(options)"); + Assert.That( + CountOccurrences(text, "_bugsee_relaunch(" + ForIosJsonCall + ")"), + Is.EqualTo(1), + "Relaunch must JSON-encode OptionPlatformGate.ForIos(options)"); + } + + static int CountOccurrences(string text, string pattern) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(pattern)) + return 0; + var count = 0; + var index = 0; + while (true) + { + index = text.IndexOf(pattern, index, StringComparison.Ordinal); + if (index < 0) + return count; + count++; + index += pattern.Length; + } + } + + [Test] + public void AndroidBridge_does_not_reference_option_platform_gate() + { + string text = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(text, Does.Not.Contain("OptionPlatformGate")); + } + } +} From 24882dd98f391290f36c5e41ac09944e5d81265c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:04:59 +0500 Subject: [PATCH 036/157] Drop filtered events when the Unity callback throws or is missing. Co-authored-by: Cursor --- .gitignore | 2 + Plugins/iOS/BugseeUnityCallbacks.mm | 30 ++- .../Android/Proxies/CallbackProxies.cs | 4 +- Runtime/Platform/IOS/IosNativeCallbacks.cs | 11 +- Runtime/WrapperPolicy/FilterCompletion.cs | 10 + .../WrapperPolicy/FilterCompletion.cs.meta | 11 ++ Tests~/WrapperPolicy/FilterCompletionTests.cs | 174 ++++++++++++++++++ 7 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 Runtime/WrapperPolicy/FilterCompletion.cs create mode 100644 Runtime/WrapperPolicy/FilterCompletion.cs.meta create mode 100644 Tests~/WrapperPolicy/FilterCompletionTests.cs diff --git a/.gitignore b/.gitignore index 4a7c04c..5cc7f37 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ Native~/ios/Bugsee/Bugsee.xcframework/ .worktrees/ # Native bridge test build output +Tests~/**/bin/ +Tests~/**/obj/ Tests~/**/build/ Tests~/**/.build/ Tests~/Android/local.properties diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index f27cda9..fcf9c4f 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -32,6 +32,9 @@ static BugseeUnityFilterCb gFilterCb = NULL; static BugseeUnityReportCb gReportCb = NULL; static BugseeUnityLifecycleCb gLifecycleCb = NULL; +static BOOL gNetworkFilterCallbackInstalled = NO; +static BOOL gLogFilterCallbackInstalled = NO; +static BOOL gBreadcrumbFilterCallbackInstalled = NO; static NSMutableDictionary *gPending; static int64_t gNextRequestId = 1; @@ -450,14 +453,19 @@ void _bugsee_set_wrapper_context(const char *json) void _bugsee_set_network_filter_enabled(int enabled) { if (!enabled) { - [Bugsee setNetworkEventFilter:nil]; + gNetworkFilterCallbackInstalled = NO; return; } + gNetworkFilterCallbackInstalled = YES; [Bugsee setNetworkEventFilter:^(BugseeNetworkEvent *event, BugseeNetworkFilterDecisionBlock decisionBlock) { - if (!gFilterCb) { + if (!gNetworkFilterCallbackInstalled) { decisionBlock(event); return; } + if (!gFilterCb) { + decisionBlock(nil); + return; + } int64_t requestId = BugseeUnityEnqueueFilter(@"network", event, decisionBlock); NSString *json = BugseeUnityJsonString(BugseeUnityNetworkToDict(event)); char *copy = BugseeUnityCopyUTF8(json); @@ -469,14 +477,19 @@ void _bugsee_set_network_filter_enabled(int enabled) void _bugsee_set_log_filter_enabled(int enabled) { if (!enabled) { - [Bugsee setLogEventFilter:nil]; + gLogFilterCallbackInstalled = NO; return; } + gLogFilterCallbackInstalled = YES; [Bugsee setLogEventFilter:^(BugseeLogEvent *event, BugseeLogFilterDecisionBlock decisionBlock) { - if (!gFilterCb) { + if (!gLogFilterCallbackInstalled) { decisionBlock(event); return; } + if (!gFilterCb) { + decisionBlock(nil); + return; + } int64_t requestId = BugseeUnityEnqueueFilter(@"log", event, decisionBlock); NSString *json = BugseeUnityJsonString(BugseeUnityLogToDict(event)); char *copy = BugseeUnityCopyUTF8(json); @@ -488,14 +501,19 @@ void _bugsee_set_log_filter_enabled(int enabled) void _bugsee_set_breadcrumb_filter_enabled(int enabled) { if (!enabled) { - [Bugsee setBreadcrumbFilter:nil]; + gBreadcrumbFilterCallbackInstalled = NO; return; } + gBreadcrumbFilterCallbackInstalled = YES; [Bugsee setBreadcrumbFilter:^(id crumb, BugseeBreadcrumbFilterDecisionBlock decisionBlock) { - if (!gFilterCb) { + if (!gBreadcrumbFilterCallbackInstalled) { decisionBlock(crumb); return; } + if (!gFilterCb) { + decisionBlock(nil); + return; + } int64_t requestId = BugseeUnityEnqueueFilter(@"breadcrumb", crumb, decisionBlock); NSString *json = BugseeUnityJsonString(BugseeUnityBreadcrumbToDict(crumb)); char *copy = BugseeUnityCopyUTF8(json); diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index b3e1126..f729b56 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -45,8 +45,8 @@ public void filter(AndroidJavaObject data, AndroidJavaObject callback) } catch (Exception ex) { - Debug.LogException(ex); - callback?.Call("run", data); + Debug.Log("filter-failed " + ex.GetType().Name); + callback?.Call("run", (AndroidJavaObject)null); } }); } diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index db96c6c..db97d18 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -6,6 +6,7 @@ using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Reporting; using Bugsee.Internal; +using Bugsee.WrapperPolicy; using UnityEngine; namespace Bugsee.Platform.IOS @@ -134,14 +135,14 @@ void Work() CompleteBreadcrumb(requestId, json); break; default: - _bugsee_complete_filter(requestId, 1, json); + _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); break; } } catch (Exception ex) { Debug.LogException(ex); - _bugsee_complete_filter(requestId, 1, json); + _bugsee_complete_filter(requestId, FilterCompletion.OnThrow, null); } } @@ -154,7 +155,7 @@ static void CompleteNetwork(long requestId, string json) var filter = _networkFilter; if (filter == null) { - _bugsee_complete_filter(requestId, 1, json); + _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); return; } @@ -176,7 +177,7 @@ static void CompleteLog(long requestId, string json) var filter = _logFilter; if (filter == null) { - _bugsee_complete_filter(requestId, 1, json); + _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); return; } @@ -198,7 +199,7 @@ static void CompleteBreadcrumb(long requestId, string json) var filter = _breadcrumbFilter; if (filter == null) { - _bugsee_complete_filter(requestId, 1, json); + _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); return; } diff --git a/Runtime/WrapperPolicy/FilterCompletion.cs b/Runtime/WrapperPolicy/FilterCompletion.cs new file mode 100644 index 0000000..c98cb16 --- /dev/null +++ b/Runtime/WrapperPolicy/FilterCompletion.cs @@ -0,0 +1,10 @@ +namespace Bugsee.WrapperPolicy +{ + public static class FilterCompletion + { + public const int Drop = 0; + public const int Keep = 1; + + public static int OnThrow => Drop; + } +} diff --git a/Runtime/WrapperPolicy/FilterCompletion.cs.meta b/Runtime/WrapperPolicy/FilterCompletion.cs.meta new file mode 100644 index 0000000..2dc2912 --- /dev/null +++ b/Runtime/WrapperPolicy/FilterCompletion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c3f2a1d9e4b4f6a8b0c1d2e3f4a5b6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/FilterCompletionTests.cs b/Tests~/WrapperPolicy/FilterCompletionTests.cs new file mode 100644 index 0000000..043a271 --- /dev/null +++ b/Tests~/WrapperPolicy/FilterCompletionTests.cs @@ -0,0 +1,174 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class FilterCompletionTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string ExtractMethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing method: " + signature); + int brace = source.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) + return source.Substring(start, i - start + 1); + } + } + + Assert.Fail("Unclosed method body: " + signature); + return null; + } + + static string DecisionBlockAfter(string body, string precedingCheck) + { + int check = body.IndexOf(precedingCheck, StringComparison.Ordinal); + Assert.That(check, Is.GreaterThanOrEqualTo(0), "Missing check: " + precedingCheck); + int decision = body.IndexOf("decisionBlock(", check, StringComparison.Ordinal); + Assert.That(decision, Is.GreaterThanOrEqualTo(0)); + int end = body.IndexOf(')', decision); + Assert.That(end, Is.GreaterThan(decision)); + return body.Substring(decision, end - decision + 1); + } + + static void AssertDisableBranchSetsFlag(string setFilterBody, string flagName, string nilSetter) + { + Assert.That(setFilterBody, Does.Not.Contain(nilSetter)); + int disabled = setFilterBody.IndexOf("if (!enabled)", StringComparison.Ordinal); + Assert.That(disabled, Is.GreaterThanOrEqualTo(0)); + int disabledReturn = setFilterBody.IndexOf("return;", disabled, StringComparison.Ordinal); + Assert.That(disabledReturn, Is.GreaterThan(disabled)); + string disableBranch = setFilterBody.Substring(disabled, disabledReturn - disabled); + Assert.That(disableBranch, Does.Contain(flagName + " = NO")); + } + + static void AssertNativeSetFilter( + string mm, + string signature, + string flagName, + string nilSetter, + string keepDecisionBlock) + { + string body = ExtractMethodBody(mm, signature); + AssertDisableBranchSetsFlag(body, flagName, nilSetter); + Assert.That( + DecisionBlockAfter(body, "if (!" + flagName + ")"), + Is.EqualTo(keepDecisionBlock)); + Assert.That( + DecisionBlockAfter(body, "if (!gFilterCb)"), + Is.EqualTo("decisionBlock(nil)")); + } + + static void AssertNullFilterDrops(string ios, string methodSignature) + { + string body = ExtractMethodBody(ios, methodSignature); + int nullFilter = body.IndexOf("if (filter == null)", StringComparison.Ordinal); + Assert.That(nullFilter, Is.GreaterThanOrEqualTo(0)); + int complete = body.IndexOf("_bugsee_complete_filter", nullFilter, StringComparison.Ordinal); + Assert.That(complete, Is.GreaterThan(nullFilter)); + int lineEnd = body.IndexOf(';', complete); + string completion = body.Substring(complete, lineEnd - complete); + Assert.That(completion, Is.EqualTo("_bugsee_complete_filter(requestId, FilterCompletion.Drop, null)")); + } + + [Test] + public void Throwing_filter_completion_is_drop() + { + Assert.That(FilterCompletion.Drop, Is.EqualTo(0)); + Assert.That(FilterCompletion.Keep, Is.EqualTo(1)); + Assert.That(FilterCompletion.OnThrow, Is.EqualTo(FilterCompletion.Drop)); + } + + [Test] + public void Ios_on_filter_fail_closed() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosNativeCallbacks.cs")); + string onFilter = ExtractMethodBody(ios, "static void OnFilter(long requestId, int kind, string json)"); + Assert.That(onFilter, Does.Not.Contain("_bugsee_complete_filter(requestId, 1, json)")); + Assert.That(onFilter, Does.Not.Contain("FilterCompletion.Keep")); + + string defaultBranch = ExtractMethodBody(onFilter, "default:"); + Assert.That(defaultBranch, Does.Contain("FilterCompletion.Drop")); + Assert.That(defaultBranch, Does.Contain("null")); + + string catchBody = ExtractMethodBody(onFilter, "catch (Exception ex)"); + Assert.That( + catchBody, + Does.Match("FilterCompletion\\.(Drop|OnThrow).*null|null.*FilterCompletion\\.(Drop|OnThrow)")); + Assert.That(catchBody, Does.Not.Contain("FilterCompletion.Keep")); + } + + [Test] + public void Ios_complete_methods_drop_when_filter_null() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosNativeCallbacks.cs")); + AssertNullFilterDrops(ios, "static void CompleteNetwork(long requestId, string json)"); + AssertNullFilterDrops(ios, "static void CompleteLog(long requestId, string json)"); + AssertNullFilterDrops(ios, "static void CompleteBreadcrumb(long requestId, string json)"); + } + + [Test] + public void Ios_native_per_kind_filter_flags() + { + string mm = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + Assert.That(mm, Does.Contain("gNetworkFilterCallbackInstalled")); + Assert.That(mm, Does.Contain("gLogFilterCallbackInstalled")); + Assert.That(mm, Does.Contain("gBreadcrumbFilterCallbackInstalled")); + Assert.That(mm, Does.Not.Contain("static BOOL gFilterCallbackInstalled")); + + AssertNativeSetFilter( + mm, + "void _bugsee_set_network_filter_enabled(int enabled)", + "gNetworkFilterCallbackInstalled", + "setNetworkEventFilter:nil", + "decisionBlock(event)"); + AssertNativeSetFilter( + mm, + "void _bugsee_set_log_filter_enabled(int enabled)", + "gLogFilterCallbackInstalled", + "setLogEventFilter:nil", + "decisionBlock(event)"); + AssertNativeSetFilter( + mm, + "void _bugsee_set_breadcrumb_filter_enabled(int enabled)", + "gBreadcrumbFilterCallbackInstalled", + "setBreadcrumbFilter:nil", + "decisionBlock(crumb)"); + } + + [Test] + public void Android_filter_proxy_drops_on_throw() + { + string proxies = File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs")); + string filterProxy = ExtractMethodBody(proxies, "public void filter(AndroidJavaObject data, AndroidJavaObject callback)"); + int catchStart = filterProxy.IndexOf("catch (Exception ex)", StringComparison.Ordinal); + Assert.That(catchStart, Is.GreaterThanOrEqualTo(0)); + string catchBody = ExtractMethodBody(filterProxy, "catch (Exception ex)"); + Assert.That(catchBody, Does.Contain("filter-failed")); + Assert.That(catchBody, Does.Not.Contain("callback?.Call(\"run\", data);")); + Assert.That(catchBody, Does.Contain("(AndroidJavaObject)null")); + } + } +} From 9d774115665fe4f3b57ba7efa9131abb5336051a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:12:34 +0500 Subject: [PATCH 037/157] Keep shared DetectAndReportExit on iOS launch options. Co-authored-by: Cursor --- Runtime/WrapperPolicy/OptionPlatformGate.cs | 1 - Tests~/WrapperPolicy/OptionPlatformGateTests.cs | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Runtime/WrapperPolicy/OptionPlatformGate.cs b/Runtime/WrapperPolicy/OptionPlatformGate.cs index 2aa79da..3e4cab7 100644 --- a/Runtime/WrapperPolicy/OptionPlatformGate.cs +++ b/Runtime/WrapperPolicy/OptionPlatformGate.cs @@ -13,7 +13,6 @@ static HashSet BuildAndroidOnlyKeys() { return new HashSet { - Options.DetectAndReportExit, Options.DetectAndReportExitLowMemory, Options.DetectAndReportExitNotResponding, Options.DetectAndReportExitNotRespondingAsCrash, diff --git a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs index 6f38a3a..37d3250 100644 --- a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs +++ b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs @@ -39,7 +39,8 @@ static HashSet ExpectedAndroidOnlyKeysFromOptionsContract() if (field.FieldType != typeof(string)) continue; var name = field.Name; - if (name.StartsWith("DetectAndReportExit", StringComparison.Ordinal)) + if (name.StartsWith("DetectAndReportExit", StringComparison.Ordinal) + && !string.Equals(name, nameof(Options.DetectAndReportExit), StringComparison.Ordinal)) expected.Add((string)field.GetValue(null)); } @@ -54,7 +55,8 @@ public void Ios_launch_drops_android_exit_and_trigger_keys() var options = new Dictionary { [Options.CaptureVideo] = true, - [Options.DetectAndReportExit] = true, + [Options.DetectAndReportExit] = false, + [Options.DetectAndReportExitLowMemory] = true, [Options.ReportingTriggerByNotification] = true, [Options.Endpoint] = "https://example.test" }; @@ -63,7 +65,9 @@ public void Ios_launch_drops_android_exit_and_trigger_keys() Assert.That(ios.ContainsKey(Options.CaptureVideo), Is.True); Assert.That(ios.ContainsKey(Options.Endpoint), Is.True); - Assert.That(ios.ContainsKey(Options.DetectAndReportExit), Is.False); + Assert.That(ios.ContainsKey(Options.DetectAndReportExit), Is.True); + Assert.That(ios[Options.DetectAndReportExit], Is.EqualTo(false)); + Assert.That(ios.ContainsKey(Options.DetectAndReportExitLowMemory), Is.False); Assert.That(ios.ContainsKey(Options.ReportingTriggerByNotification), Is.False); } From 954b0fb57265e03cb018f78918b5333faea1c13e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:17:23 +0500 Subject: [PATCH 038/157] Wire Android AddNetworkEvent to channelAddNetwork. Stop clearing the wrapper channel on Android Stop so channel sinks survive relaunch. Co-authored-by: Cursor --- Runtime/Bugsee.cs | 3 +++ Runtime/Platform/Android/AndroidBridge.cs | 20 +++++++++++++++---- .../Platform/Android/AndroidExchangeEvents.cs | 2 ++ Runtime/Platform/Editor/EditorBridge.cs | 1 + Runtime/Platform/IBugseeNativeBridge.cs | 3 +++ Runtime/Platform/IOS/IOSBridge.cs | 5 +++++ .../ChannelNetworkSourceTests.cs | 11 ++++++++++ 7 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index d2d1f4e..d94a8ca 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -82,6 +82,9 @@ public static void Relaunch(Dictionary options = null) => public static void Log(string message, LogLevel level = LogLevel.Info) => Bridge.Log(message, level); + public static void AddNetworkEvent(INetworkEvent networkEvent) => + Bridge.AddNetworkEvent(networkEvent); + public static void Trace(string name, object value) => Bridge.Trace(name, value); public static void Event(string name, IDictionary parameters = null) => diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index f7c27d4..97b9ea3 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -90,10 +90,6 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { HostLogForwarder.Uninstall(); - using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) - { - wrapper.CallStatic("clearWrapperChannel"); - } ExceptionPipeline.Uninstall(); if (completion == null) { @@ -130,6 +126,22 @@ public void ChannelLog(string message, LogLevel level) } } + public void AddNetworkEvent(INetworkEvent networkEvent) + { + if (networkEvent == null) return; + if (!(networkEvent is AndroidNetworkEvent androidEvent)) + { + throw new ArgumentException( + "networkEvent must be a native Android network event from the Bugsee exchange factory.", + nameof(networkEvent)); + } + + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) + { + wrapper.CallStatic("channelAddNetwork", androidEvent.Native); + } + } + public void Log(string message, LogLevel level) { using (var clazz = new AndroidJavaClass("com.bugsee.library.contracts.options.LogLevel")) diff --git a/Runtime/Platform/Android/AndroidExchangeEvents.cs b/Runtime/Platform/Android/AndroidExchangeEvents.cs index f9019c5..b2a04c1 100644 --- a/Runtime/Platform/Android/AndroidExchangeEvents.cs +++ b/Runtime/Platform/Android/AndroidExchangeEvents.cs @@ -15,6 +15,8 @@ sealed class AndroidNetworkEvent : INetworkEvent { readonly AndroidJavaObject _native; + internal AndroidJavaObject Native => _native; + public AndroidNetworkEvent(AndroidJavaObject native) { _native = native; diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index 2481a24..4f17eb0 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -31,6 +31,7 @@ public void Relaunch(IDictionary options) { } public bool IsBlackout() => _blackout; public void Log(string message, LogLevel level) { } public void ChannelLog(string message, LogLevel level) { } + public void AddNetworkEvent(INetworkEvent networkEvent) { } public void Trace(string name, object value) { } public void Event(string name, IDictionary parameters) { } public void LogException(Exception exception, IDictionary options = null) { } diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index 049bda0..11057a8 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -29,6 +29,9 @@ public interface IBugseeNativeBridge /// Submit a host log on the wrapper channel. Public stays on the SDK log API. void ChannelLog(string message, LogLevel level); + + /// Submit a network event on the wrapper channel, not the public SDK addNetworkEvent API. + void AddNetworkEvent(INetworkEvent networkEvent); void Trace(string name, object value); void Event(string name, IDictionary parameters); void LogException(Exception exception, IDictionary options = null); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 14217c8..eb09917 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -103,6 +103,11 @@ public void ChannelLog(string message, LogLevel level) _bugsee_channel_log(message, (int)level, WrapperLogSourcePolicy.Resolve(null)); } + public void AddNetworkEvent(INetworkEvent networkEvent) + { + // iOS wrapper-channel network export lands in a follow-up task. + } + public void Log(string message, LogLevel level) => _bugsee_log(message ?? "", (int)level); diff --git a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs index 4db60f7..0c80126 100644 --- a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs +++ b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs @@ -68,5 +68,16 @@ public void Proguard_keeps_jni_channelAddNetwork_entry_point() string proguard = File.ReadAllText(RepoFile("Plugins/Android/bugsee-unity-wrapper.proguard")); Assert.That(proguard, Does.Contain("channelAddNetwork(com.bugsee.library.contracts.exchange.NetworkEvent)")); } + + [Test] + public void Android_bridge_submits_through_channelAddNetwork_not_public_addNetworkEvent() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(android, Does.Contain("\"channelAddNetwork\"")); + Assert.That(android, Does.Not.Contain("CallStatic(\"addNetworkEvent\"")); + + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + Assert.That(facade, Does.Contain("AddNetworkEvent")); + } } } From b4c3fc56b8e61e803f0d3c371b03658f4b2a17df Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:17:23 +0500 Subject: [PATCH 039/157] Passthrough in-flight iOS filter events after unsubscribe. Co-authored-by: Cursor --- Runtime/Platform/IOS/IosNativeCallbacks.cs | 26 ++++++++++--------- Tests~/WrapperPolicy/FilterCompletionTests.cs | 18 ++++++++----- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index db97d18..575a502 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -118,6 +118,11 @@ public static void SetReportHandler(IReportHandler handler) [MonoPInvokeCallback(typeof(FilterCb))] static void OnFilter(long requestId, int kind, string json) { + // Snapshot before the main-thread hop so in-flight events keep the filter that was active when native captured them. + var networkFilter = _networkFilter; + var logFilter = _logFilter; + var breadcrumbFilter = _breadcrumbFilter; + // Filters may touch Unity APIs — hop to main thread when needed. void Work() { @@ -126,13 +131,13 @@ void Work() switch (kind) { case KindNetwork: - CompleteNetwork(requestId, json); + CompleteNetwork(requestId, json, networkFilter); break; case KindLog: - CompleteLog(requestId, json); + CompleteLog(requestId, json, logFilter); break; case KindBreadcrumb: - CompleteBreadcrumb(requestId, json); + CompleteBreadcrumb(requestId, json, breadcrumbFilter); break; default: _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); @@ -150,12 +155,11 @@ void Work() else MainThreadDispatcher.Run(Work); } - static void CompleteNetwork(long requestId, string json) + static void CompleteNetwork(long requestId, string json, EventFilter filter) { - var filter = _networkFilter; if (filter == null) { - _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); + _bugsee_complete_filter(requestId, FilterCompletion.Keep, json); return; } @@ -172,12 +176,11 @@ static void CompleteNetwork(long requestId, string json) _bugsee_complete_filter(requestId, 1, ToNativeMapJson(ios.ToResultJson(), "headers")); } - static void CompleteLog(long requestId, string json) + static void CompleteLog(long requestId, string json, EventFilter filter) { - var filter = _logFilter; if (filter == null) { - _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); + _bugsee_complete_filter(requestId, FilterCompletion.Keep, json); return; } @@ -194,12 +197,11 @@ static void CompleteLog(long requestId, string json) _bugsee_complete_filter(requestId, 1, ios.ToResultJson()); } - static void CompleteBreadcrumb(long requestId, string json) + static void CompleteBreadcrumb(long requestId, string json, EventFilter filter) { - var filter = _breadcrumbFilter; if (filter == null) { - _bugsee_complete_filter(requestId, FilterCompletion.Drop, null); + _bugsee_complete_filter(requestId, FilterCompletion.Keep, json); return; } diff --git a/Tests~/WrapperPolicy/FilterCompletionTests.cs b/Tests~/WrapperPolicy/FilterCompletionTests.cs index 043a271..458ba4d 100644 --- a/Tests~/WrapperPolicy/FilterCompletionTests.cs +++ b/Tests~/WrapperPolicy/FilterCompletionTests.cs @@ -81,7 +81,7 @@ static void AssertNativeSetFilter( Is.EqualTo("decisionBlock(nil)")); } - static void AssertNullFilterDrops(string ios, string methodSignature) + static void AssertNullFilterKeeps(string ios, string methodSignature) { string body = ExtractMethodBody(ios, methodSignature); int nullFilter = body.IndexOf("if (filter == null)", StringComparison.Ordinal); @@ -90,7 +90,7 @@ static void AssertNullFilterDrops(string ios, string methodSignature) Assert.That(complete, Is.GreaterThan(nullFilter)); int lineEnd = body.IndexOf(';', complete); string completion = body.Substring(complete, lineEnd - complete); - Assert.That(completion, Is.EqualTo("_bugsee_complete_filter(requestId, FilterCompletion.Drop, null)")); + Assert.That(completion, Is.EqualTo("_bugsee_complete_filter(requestId, FilterCompletion.Keep, json)")); } [Test] @@ -121,12 +121,18 @@ public void Ios_on_filter_fail_closed() } [Test] - public void Ios_complete_methods_drop_when_filter_null() + public void Ios_complete_methods_keep_when_filter_null() { string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosNativeCallbacks.cs")); - AssertNullFilterDrops(ios, "static void CompleteNetwork(long requestId, string json)"); - AssertNullFilterDrops(ios, "static void CompleteLog(long requestId, string json)"); - AssertNullFilterDrops(ios, "static void CompleteBreadcrumb(long requestId, string json)"); + string onFilter = ExtractMethodBody(ios, "static void OnFilter(long requestId, int kind, string json)"); + Assert.That(onFilter, Does.Contain("var networkFilter = _networkFilter")); + Assert.That(onFilter, Does.Contain("var logFilter = _logFilter")); + Assert.That(onFilter, Does.Contain("var breadcrumbFilter = _breadcrumbFilter")); + AssertNullFilterKeeps(ios, "static void CompleteNetwork(long requestId, string json, EventFilter filter)"); + AssertNullFilterKeeps(ios, "static void CompleteLog(long requestId, string json, EventFilter filter)"); + AssertNullFilterKeeps( + ios, + "static void CompleteBreadcrumb(long requestId, string json, EventFilter filter)"); } [Test] From 8bee551140205a09039d5c4f00157f4416b6bc67 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:26:30 +0500 Subject: [PATCH 040/157] Expose exchange factory and mint network events for channel submit. Copy arbitrary INetworkEvent values onto factory-created Android events before channelAddNetwork. Co-authored-by: Cursor --- Runtime/Bugsee.cs | 3 ++ .../Exchange/IBugseeExchangeFactory.cs | 13 +++++ Runtime/Platform/Android/AndroidBridge.cs | 48 +++++++++++++++-- .../Android/AndroidExchangeFactory.cs | 39 ++++++++++++++ Runtime/Platform/Editor/EditorBridge.cs | 52 +++++++++++++++++++ Runtime/Platform/IBugseeNativeBridge.cs | 2 + Runtime/Platform/IOS/IOSBridge.cs | 21 ++++++++ .../ChannelNetworkSourceTests.cs | 5 ++ 8 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs create mode 100644 Runtime/Platform/Android/AndroidExchangeFactory.cs diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index d94a8ca..20a67fe 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -85,6 +85,9 @@ public static void Log(string message, LogLevel level = LogLevel.Info) => public static void AddNetworkEvent(INetworkEvent networkEvent) => Bridge.AddNetworkEvent(networkEvent); + public static IBugseeExchangeFactory GetExchangeFactory() => + Bridge.GetExchangeFactory(); + public static void Trace(string name, object value) => Bridge.Trace(name, value); public static void Event(string name, IDictionary parameters = null) => diff --git a/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs b/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs new file mode 100644 index 0000000..6cb447c --- /dev/null +++ b/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs @@ -0,0 +1,13 @@ +namespace Bugsee.Contracts.Exchange +{ + /// Mirrors Bugsee.getExchangeFactory() on Android 7.x. + public interface IBugseeExchangeFactory + { + INetworkEvent CreateNetworkEvent( + long timestamp, + NetworkEventStage stage, + string url, + string method, + string mechanism); + } +} diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 97b9ea3..9b54f38 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -29,6 +29,8 @@ sealed class AndroidBridge : IBugseeNativeBridge EventFilterProxy _breadcrumbFilterProxy; AndroidFeedback _feedback; AndroidAppearance _appearance; + IBugseeExchangeFactory _exchangeFactory; + static bool _loggedMissingNetworkFactory; public bool IsSupported => true; @@ -126,14 +128,39 @@ public void ChannelLog(string message, LogLevel level) } } + public IBugseeExchangeFactory GetExchangeFactory() + { + if (_exchangeFactory != null) return _exchangeFactory; + var javaFactory = _bugsee.CallStatic("getExchangeFactory"); + if (javaFactory == null) return null; + _exchangeFactory = new AndroidExchangeFactory(javaFactory); + return _exchangeFactory; + } + public void AddNetworkEvent(INetworkEvent networkEvent) { if (networkEvent == null) return; - if (!(networkEvent is AndroidNetworkEvent androidEvent)) + var androidEvent = networkEvent as AndroidNetworkEvent; + if (androidEvent == null) { - throw new ArgumentException( - "networkEvent must be a native Android network event from the Bugsee exchange factory.", - nameof(networkEvent)); + var factory = GetExchangeFactory(); + if (factory == null) + { + if (!_loggedMissingNetworkFactory) + { + _loggedMissingNetworkFactory = true; + Debug.LogWarning("[Bugsee] network-factory-missing"); + } + return; + } + + androidEvent = (AndroidNetworkEvent)factory.CreateNetworkEvent( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + networkEvent.Stage, + networkEvent.Url, + networkEvent.Method, + networkEvent.Mechanism); + CopyNetworkFields(networkEvent, androidEvent); } using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) @@ -142,6 +169,19 @@ public void AddNetworkEvent(INetworkEvent networkEvent) } } + static void CopyNetworkFields(INetworkEvent source, INetworkEvent target) + { + if (source == null || target == null || ReferenceEquals(source, target)) return; + target.Url = source.Url; + target.Body = source.Body; + target.Size = source.Size; + target.ResponseCode = source.ResponseCode; + target.StatusText = source.StatusText; + target.ErrorShortMessage = source.ErrorShortMessage; + target.ErrorDescription = source.ErrorDescription; + target.Headers = source.Headers; + } + public void Log(string message, LogLevel level) { using (var clazz = new AndroidJavaClass("com.bugsee.library.contracts.options.LogLevel")) diff --git a/Runtime/Platform/Android/AndroidExchangeFactory.cs b/Runtime/Platform/Android/AndroidExchangeFactory.cs new file mode 100644 index 0000000..5243857 --- /dev/null +++ b/Runtime/Platform/Android/AndroidExchangeFactory.cs @@ -0,0 +1,39 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using Bugsee.Contracts.Exchange; +using UnityEngine; + +namespace Bugsee.Platform.Android +{ + sealed class AndroidExchangeFactory : IBugseeExchangeFactory + { + readonly AndroidJavaObject _factory; + + internal AndroidExchangeFactory(AndroidJavaObject factory) + { + _factory = factory; + } + + public INetworkEvent CreateNetworkEvent( + long timestamp, + NetworkEventStage stage, + string url, + string method, + string mechanism) + { + using (var stageClass = new AndroidJavaClass( + "com.bugsee.library.contracts.exchange.NetworkEventStage")) + using (var javaStage = stageClass.CallStatic("valueOf", stage.ToString())) + { + var native = _factory.Call( + "createNetworkEvent", + timestamp, + javaStage, + url ?? "", + method ?? "", + mechanism ?? ""); + return new AndroidNetworkEvent(native); + } + } + } +} +#endif diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index 4f17eb0..7cc340a 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -32,6 +32,9 @@ public void Relaunch(IDictionary options) { } public void Log(string message, LogLevel level) { } public void ChannelLog(string message, LogLevel level) { } public void AddNetworkEvent(INetworkEvent networkEvent) { } + + public IBugseeExchangeFactory GetExchangeFactory() => EditorExchangeFactory.Instance; + public void Trace(string name, object value) { } public void Event(string name, IDictionary parameters) { } public void LogException(Exception exception, IDictionary options = null) { } @@ -76,5 +79,54 @@ public void ShowFeedbackUi() { } public void SetDefaultGreeting(string greeting) { } public void SetListener(IFeedbackListener listener) { } } + + sealed class EditorExchangeFactory : IBugseeExchangeFactory + { + public static readonly EditorExchangeFactory Instance = new EditorExchangeFactory(); + + public INetworkEvent CreateNetworkEvent( + long timestamp, + NetworkEventStage stage, + string url, + string method, + string mechanism) => + new EditorNetworkEvent(timestamp, stage, url, method, mechanism); + } + + sealed class EditorNetworkEvent : INetworkEvent + { + readonly long _timestamp; + readonly NetworkEventStage _stage; + readonly string _method; + readonly string _mechanism; + + public EditorNetworkEvent( + long timestamp, + NetworkEventStage stage, + string url, + string method, + string mechanism) + { + _timestamp = timestamp; + _stage = stage; + Url = url; + _method = method; + _mechanism = mechanism; + Id = Guid.NewGuid().ToString("N"); + } + + public string Id { get; } + public string Mechanism => _mechanism; + public string Url { get; set; } + public string Method => _method; + public string Body { get; set; } + public long Size { get; set; } + public int ResponseCode { get; set; } + public string StatusText { get; set; } + public string ErrorShortMessage { get; set; } + public string ErrorDescription { get; set; } + public IDictionary Headers { get; set; } + public NetworkEventStage Stage => _stage; + } } } diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index 11057a8..69e7f30 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -32,6 +32,8 @@ public interface IBugseeNativeBridge /// Submit a network event on the wrapper channel, not the public SDK addNetworkEvent API. void AddNetworkEvent(INetworkEvent networkEvent); + + IBugseeExchangeFactory GetExchangeFactory(); void Trace(string name, object value); void Event(string name, IDictionary parameters); void LogException(Exception exception, IDictionary options = null); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index eb09917..2e25b2c 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -108,6 +108,8 @@ public void AddNetworkEvent(INetworkEvent networkEvent) // iOS wrapper-channel network export lands in a follow-up task. } + public IBugseeExchangeFactory GetExchangeFactory() => IosExchangeFactory.Instance; + public void Log(string message, LogLevel level) => _bugsee_log(message ?? "", (int)level); @@ -368,5 +370,24 @@ public IReadOnlyDictionary ToMap() => new Dictionary(_cache); } } + + sealed class IosExchangeFactory : IBugseeExchangeFactory + { + public static readonly IosExchangeFactory Instance = new IosExchangeFactory(); + + public INetworkEvent CreateNetworkEvent( + long timestamp, + NetworkEventStage stage, + string url, + string method, + string mechanism) => + new IosNetworkEvent(new IosNetworkDto + { + url = url, + method = method, + mechanism = mechanism, + stage = stage.ToString(), + }); + } } #endif diff --git a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs index 0c80126..6835270 100644 --- a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs +++ b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs @@ -75,9 +75,14 @@ public void Android_bridge_submits_through_channelAddNetwork_not_public_addNetwo string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); Assert.That(android, Does.Contain("\"channelAddNetwork\"")); Assert.That(android, Does.Not.Contain("CallStatic(\"addNetworkEvent\"")); + Assert.That(android, Does.Contain("getExchangeFactory")); + + string factory = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidExchangeFactory.cs")); + Assert.That(factory, Does.Contain("createNetworkEvent")); string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); Assert.That(facade, Does.Contain("AddNetworkEvent")); + Assert.That(facade, Does.Contain("GetExchangeFactory")); } } } From f506895e44c4ced3d1d9ff13b1fadf9c7e54fe6c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 14:34:02 +0500 Subject: [PATCH 041/157] Match Android 7.3.0 network event factory JNI. Use NetworkEvent$NetworkEventStage, id/mechanism/method argument order, and null native handling. Co-authored-by: Cursor --- .../Exchange/IBugseeExchangeFactory.cs | 10 +++++++--- .../Exchange/IBugseeExchangeFactory.cs.meta | 11 +++++++++++ Runtime/Platform/Android/AndroidBridge.cs | 7 ++++--- .../Platform/Android/AndroidExchangeFactory.cs | 15 ++++++++------- .../Android/AndroidExchangeFactory.cs.meta | 11 +++++++++++ Runtime/Platform/Editor/EditorBridge.cs | 17 ++++++++--------- Runtime/Platform/IOS/IOSBridge.cs | 8 ++++---- .../WrapperPolicy/ChannelNetworkSourceTests.cs | 11 +++++++++++ 8 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs.meta create mode 100644 Runtime/Platform/Android/AndroidExchangeFactory.cs.meta diff --git a/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs b/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs index 6cb447c..dbc57a4 100644 --- a/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs +++ b/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs @@ -3,11 +3,15 @@ namespace Bugsee.Contracts.Exchange /// Mirrors Bugsee.getExchangeFactory() on Android 7.x. public interface IBugseeExchangeFactory { + /// + /// Short Android 7.x create: (timestamp, stage, id, mechanism, method). + /// Pass null for an SDK-generated UUID; set URL via after create. + /// INetworkEvent CreateNetworkEvent( long timestamp, NetworkEventStage stage, - string url, - string method, - string mechanism); + string id, + string mechanism, + string method); } } diff --git a/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs.meta b/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs.meta new file mode 100644 index 0000000..aae4517 --- /dev/null +++ b/Runtime/Contracts/Exchange/IBugseeExchangeFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f3a9c2e1b584d6a9e0f4c8d2a1b5e3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 9b54f38..08cd84f 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -157,9 +157,10 @@ public void AddNetworkEvent(INetworkEvent networkEvent) androidEvent = (AndroidNetworkEvent)factory.CreateNetworkEvent( DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), networkEvent.Stage, - networkEvent.Url, - networkEvent.Method, - networkEvent.Mechanism); + string.IsNullOrEmpty(networkEvent.Id) ? null : networkEvent.Id, + networkEvent.Mechanism, + networkEvent.Method); + if (androidEvent == null) return; CopyNetworkFields(networkEvent, androidEvent); } diff --git a/Runtime/Platform/Android/AndroidExchangeFactory.cs b/Runtime/Platform/Android/AndroidExchangeFactory.cs index 5243857..efafcce 100644 --- a/Runtime/Platform/Android/AndroidExchangeFactory.cs +++ b/Runtime/Platform/Android/AndroidExchangeFactory.cs @@ -16,21 +16,22 @@ internal AndroidExchangeFactory(AndroidJavaObject factory) public INetworkEvent CreateNetworkEvent( long timestamp, NetworkEventStage stage, - string url, - string method, - string mechanism) + string id, + string mechanism, + string method) { using (var stageClass = new AndroidJavaClass( - "com.bugsee.library.contracts.exchange.NetworkEventStage")) + "com.bugsee.library.contracts.exchange.NetworkEvent$NetworkEventStage")) using (var javaStage = stageClass.CallStatic("valueOf", stage.ToString())) { var native = _factory.Call( "createNetworkEvent", timestamp, javaStage, - url ?? "", - method ?? "", - mechanism ?? ""); + id, + mechanism ?? "", + method ?? ""); + if (native == null) return null; return new AndroidNetworkEvent(native); } } diff --git a/Runtime/Platform/Android/AndroidExchangeFactory.cs.meta b/Runtime/Platform/Android/AndroidExchangeFactory.cs.meta new file mode 100644 index 0000000..7520b0c --- /dev/null +++ b/Runtime/Platform/Android/AndroidExchangeFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e4b0d3f2c695e7b0f1a5d9e3b2c6f4a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index 7cc340a..a682870 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -87,10 +87,10 @@ sealed class EditorExchangeFactory : IBugseeExchangeFactory public INetworkEvent CreateNetworkEvent( long timestamp, NetworkEventStage stage, - string url, - string method, - string mechanism) => - new EditorNetworkEvent(timestamp, stage, url, method, mechanism); + string id, + string mechanism, + string method) => + new EditorNetworkEvent(timestamp, stage, id, mechanism, method); } sealed class EditorNetworkEvent : INetworkEvent @@ -103,16 +103,15 @@ sealed class EditorNetworkEvent : INetworkEvent public EditorNetworkEvent( long timestamp, NetworkEventStage stage, - string url, - string method, - string mechanism) + string id, + string mechanism, + string method) { _timestamp = timestamp; _stage = stage; - Url = url; _method = method; _mechanism = mechanism; - Id = Guid.NewGuid().ToString("N"); + Id = string.IsNullOrEmpty(id) ? Guid.NewGuid().ToString("N") : id; } public string Id { get; } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 2e25b2c..83bcb6c 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -378,12 +378,12 @@ sealed class IosExchangeFactory : IBugseeExchangeFactory public INetworkEvent CreateNetworkEvent( long timestamp, NetworkEventStage stage, - string url, - string method, - string mechanism) => + string id, + string mechanism, + string method) => new IosNetworkEvent(new IosNetworkDto { - url = url, + id = string.IsNullOrEmpty(id) ? Guid.NewGuid().ToString("N") : id, method = method, mechanism = mechanism, stage = stage.ToString(), diff --git a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs index 6835270..b96c83a 100644 --- a/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs +++ b/Tests~/WrapperPolicy/ChannelNetworkSourceTests.cs @@ -78,7 +78,18 @@ public void Android_bridge_submits_through_channelAddNetwork_not_public_addNetwo Assert.That(android, Does.Contain("getExchangeFactory")); string factory = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidExchangeFactory.cs")); + Assert.That(factory, Does.Contain("NetworkEvent$NetworkEventStage")); Assert.That(factory, Does.Contain("createNetworkEvent")); + Assert.That(factory, Does.Contain("id,")); + Assert.That(factory, Does.Contain("mechanism ?? \"\"")); + Assert.That(factory, Does.Contain("method ?? \"\"")); + int createIdx = factory.IndexOf("createNetworkEvent", StringComparison.Ordinal); + int idIdx = factory.IndexOf("id,", createIdx, StringComparison.Ordinal); + int mechIdx = factory.IndexOf("mechanism ??", createIdx, StringComparison.Ordinal); + int methodIdx = factory.IndexOf("method ??", createIdx, StringComparison.Ordinal); + Assert.That(idIdx, Is.LessThan(mechIdx)); + Assert.That(mechIdx, Is.LessThan(methodIdx)); + Assert.That(factory, Does.Contain("if (native == null) return null")); string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); Assert.That(facade, Does.Contain("AddNetworkEvent")); From 74e817d4f381d3655a4f54c133d14a7dcbc18cf0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:04:55 +0500 Subject: [PATCH 042/157] Log a throwing iOS filter without filing a second exception. Co-authored-by: Cursor --- Runtime/Platform/IOS/IosNativeCallbacks.cs | 2 +- Tests~/WrapperPolicy/FilterCompletionTests.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 575a502..1c4c85c 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -146,7 +146,7 @@ void Work() } catch (Exception ex) { - Debug.LogException(ex); + Debug.Log("filter-failed " + ex.GetType().Name); _bugsee_complete_filter(requestId, FilterCompletion.OnThrow, null); } } diff --git a/Tests~/WrapperPolicy/FilterCompletionTests.cs b/Tests~/WrapperPolicy/FilterCompletionTests.cs index 458ba4d..171d853 100644 --- a/Tests~/WrapperPolicy/FilterCompletionTests.cs +++ b/Tests~/WrapperPolicy/FilterCompletionTests.cs @@ -118,6 +118,9 @@ public void Ios_on_filter_fail_closed() catchBody, Does.Match("FilterCompletion\\.(Drop|OnThrow).*null|null.*FilterCompletion\\.(Drop|OnThrow)")); Assert.That(catchBody, Does.Not.Contain("FilterCompletion.Keep")); + Assert.That(catchBody, Does.Contain("filter-failed")); + Assert.That(catchBody, Does.Contain("GetType().Name")); + Assert.That(catchBody, Does.Not.Contain("LogException")); } [Test] From 922276d72ad60341c8fa94fd75867b9e3660395f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:18:39 +0500 Subject: [PATCH 043/157] Keep dying-process report work on the native side and attach by path or bytes. Co-authored-by: Cursor --- Plugins/Android/UnityWrapperProvider.java | 8 + Plugins/iOS/BugseeUnityCallbacks.mm | 28 ++- Runtime/Contracts/Reporting/IReport.cs | 3 +- Runtime/Platform/Android/AndroidBridge.cs | 23 +- Runtime/Platform/Android/AndroidReport.cs | 22 +- .../Android/Proxies/CallbackProxies.cs | 8 - Runtime/Platform/IOS/IOSBridge.cs | 23 +- Runtime/Platform/IOS/IosNativeCallbacks.cs | 29 +-- Runtime/Platform/IOS/IosReport.cs | 42 +++- .../BugseeHarness/BugseeActionCatalog.cs | 12 +- Tests~/WrapperPolicy/ReportPathSourceTests.cs | 202 ++++++++++++++++++ 11 files changed, 337 insertions(+), 63 deletions(-) create mode 100644 Tests~/WrapperPolicy/ReportPathSourceTests.cs diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index 6b68504..a8ea8c9 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -127,11 +127,19 @@ public int[] getSecureRectangles(int display) { @Override public void onBeforeReportCreated(Report report, boolean isTerminating, Runnable completion) { + if (isTerminating) { + if (completion != null) completion.run(); + return; + } if (completion != null) completion.run(); } @Override public void onAfterReportCreated(Report report, boolean isTerminating, Runnable completion) { + if (isTerminating) { + if (completion != null) completion.run(); + return; + } if (completion != null) completion.run(); } } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index fcf9c4f..b413c5e 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -267,20 +267,24 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar if (![item isKindOfClass:[NSDictionary class]]) continue; NSDictionary *att = (NSDictionary *)item; NSString *name = [att[@"name"] isKindOfClass:[NSString class]] ? att[@"name"] : @"attachment"; - id created = [report createAndAddAttachmentWithName:name]; - if (!created) continue; - id fileName = att[@"fileName"]; - if ([fileName isKindOfClass:[NSString class]]) created.fileName = fileName; - id mime = att[@"mimeType"]; - if ([mime isKindOfClass:[NSString class]]) created.mimeType = mime; - id text = att[@"text"]; - if ([text isKindOfClass:[NSString class]]) { - created.data = [(NSString *)text dataUsingEncoding:NSUTF8StringEncoding]; + NSString *mime = [att[@"mimeType"] isKindOfClass:[NSString class]] ? att[@"mimeType"] : @"application/octet-stream"; + id pathVal = att[@"path"]; + if ([pathVal isKindOfClass:[NSString class]] && [(NSString *)pathVal length] > 0) { + id reportObj = (id)report; + if ([reportObj respondsToSelector:@selector(addAttachmentWithFilePath:name:mimeType:move:)]) { + [reportObj addAttachmentWithFilePath:(NSString *)pathVal name:name mimeType:mime move:NO]; + } + continue; } id b64 = att[@"dataBase64"]; if ([b64 isKindOfClass:[NSString class]] && [(NSString *)b64 length] > 0) { NSData *decoded = [[NSData alloc] initWithBase64EncodedString:(NSString *)b64 options:0]; - if (decoded) created.data = decoded; + if (decoded) { + id reportObj = (id)report; + if ([reportObj respondsToSelector:@selector(addAttachmentWithData:name:mimeType:)]) { + [reportObj addAttachmentWithData:decoded name:name mimeType:mime]; + } + } } } } @@ -355,6 +359,10 @@ - (void)forwardReport:(id)report if (completion) completion(); return; } + if (isTerminating) { + if (completion) completion(); + return; + } BugseeUnityEnsureState(); int64_t requestId; @synchronized (gLock) { diff --git a/Runtime/Contracts/Reporting/IReport.cs b/Runtime/Contracts/Reporting/IReport.cs index 23d3aa9..649017f 100644 --- a/Runtime/Contracts/Reporting/IReport.cs +++ b/Runtime/Contracts/Reporting/IReport.cs @@ -28,7 +28,8 @@ public interface IReport void SetLabels(IEnumerable labels); IReadOnlyList Attachments { get; } - IAttachment CreateAndAddAttachment(string name); + IAttachment AddAttachmentFile(string path, string name, string mimeType); + IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType); void ClearAttachments(); } } diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 08cd84f..3438544 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -314,15 +314,30 @@ public void Upload(string summary, string description, IssueSeverity severity, I } } - public void SetUserIdentifier(string userIdentifier) => - _bugsee.CallStatic("setUserIdentifier", userIdentifier); + public void SetUserIdentifier(string userIdentifier) + { + var normalized = UserIdentifierPolicy.ForSet(userIdentifier); + if (normalized == null) + ClearUserIdentifier(); + else + _bugsee.CallStatic("setUserIdentifier", normalized); + } - public string GetUserIdentifier() => _bugsee.CallStatic("getUserIdentifier"); + public string GetUserIdentifier() => + UserIdentifierPolicy.ForGet(_bugsee.CallStatic("getUserIdentifier")); public void ClearUserIdentifier() => _bugsee.CallStatic("clearUserIdentifier"); - public void SetAttribute(string key, object value) => + public void SetAttribute(string key, object value) + { + var decision = AttributePolicy.Evaluate(key, value); + if (!decision.Accepted) + throw new ArgumentException(decision.Error, nameof(value)); + _bugsee.CallStatic("setAttribute", key, Box(value)); + if (GetAttribute(key) == null) + throw new ArgumentException("attribute '" + key + "' was dropped", nameof(value)); + } public object GetAttribute(string key) { diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index d37305f..b50842f 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -57,7 +57,9 @@ public IssueSeverity? Severity { using (var s = _report.Call("getSeverity")) { - var v = s?.Call("getValue") ?? (int)IssueSeverity.High; + if (s == null) + return null; + var v = s.Call("getValue"); return IssueSeverityWire.TryFromWire(v, out var severity) ? severity : (IssueSeverity?)null; } } @@ -125,10 +127,22 @@ public void SetLabels(IEnumerable labels) public IReadOnlyList Attachments => new List(); - public IAttachment CreateAndAddAttachment(string name) + public IAttachment AddAttachmentFile(string path, string name, string mimeType) { - var att = _report.Call("createAndAddAttachment", name); - return new AndroidAttachment(att); + if (string.IsNullOrEmpty(path)) + return null; + + using (var file = new AndroidJavaObject("java.io.File", path)) + { + var att = _report.Call("addAttachment", file, name, mimeType, false); + return att == null ? null : new AndroidAttachment(att); + } + } + + public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) + { + var att = _report.Call("addAttachment", data, name, mimeType); + return att == null ? null : new AndroidAttachment(att); } public void ClearAttachments() => _report.Call("clearAttachments"); diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index f729b56..1a8a3a5 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -82,16 +82,8 @@ void Invoke(AndroidJavaObject report, bool isTerminating, AndroidJavaObject comp return; } - // Terminating: must not async round-trip. if (isTerminating) { - try - { - var wrapped = new AndroidReport(report); - if (before) handler.OnBeforeReportCreated(wrapped, true, () => { }); - else handler.OnAfterReportCreated(wrapped, true, () => { }); - } - catch (Exception ex) { Debug.LogException(ex); } completion?.Call("run"); return; } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index c03331b..6e7a82e 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -155,15 +155,30 @@ public void ShowReportDialog(string summary, string description, IssueSeverity s public void Upload(string summary, string description, IssueSeverity severity, IList labels) => _bugsee_upload(summary ?? "", description ?? "", (int)severity, ToJsonStringArray(labels)); - public void SetUserIdentifier(string userIdentifier) => - _bugsee_set_email(userIdentifier ?? ""); + public void SetUserIdentifier(string userIdentifier) + { + var normalized = UserIdentifierPolicy.ForSet(userIdentifier); + if (normalized == null) + ClearUserIdentifier(); + else + _bugsee_set_email(normalized); + } - public string GetUserIdentifier() => ConsumeNativeString(_bugsee_get_email()); + public string GetUserIdentifier() => + UserIdentifierPolicy.ForGet(ConsumeNativeString(_bugsee_get_email())); public void ClearUserIdentifier() => _bugsee_clear_email(); - public void SetAttribute(string key, object value) => + public void SetAttribute(string key, object value) + { + var decision = AttributePolicy.Evaluate(key, value); + if (!decision.Accepted) + throw new ArgumentException(decision.Error, nameof(value)); + _bugsee_set_attribute(key ?? "", ToJsonValue(value)); + if (GetAttribute(key) == null) + throw new ArgumentException("attribute '" + key + "' was dropped", nameof(value)); + } public object GetAttribute(string key) { diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 575a502..c7ceec1 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -222,6 +222,12 @@ static void CompleteBreadcrumb(long requestId, string json, EventFilter { }; - if (phase == 0) - handler.OnBeforeReportCreated(report, true, noop); - else - handler.OnAfterReportCreated(report, true, noop); - } - catch (Exception ex) { Debug.LogException(ex); } - _bugsee_complete_report(requestId, report.ToResultJson()); - return; - } - var finished = false; Action done = () => { @@ -274,14 +263,10 @@ void Work() } } - if (isTerminating != 0 || MainThreadDispatcher.IsMainThread) - { + if (MainThreadDispatcher.IsMainThread) Work(); - } else - { MainThreadDispatcher.Run(Work); - } } [MonoPInvokeCallback(typeof(LifecycleCb))] diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index fd6b095..7e7016c 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -136,9 +136,26 @@ public void SetLabels(IEnumerable labels) public IReadOnlyList Attachments => _attachments; - public IAttachment CreateAndAddAttachment(string name) + public IAttachment AddAttachmentFile(string path, string name, string mimeType) { + if (string.IsNullOrEmpty(path)) + return null; var att = new IosAttachment(name ?? "attachment"); + att.Filename = name ?? "attachment"; + att.MimeType = mimeType ?? "application/octet-stream"; + att.SetFilePath(path); + _attachments.Add(att); + return att; + } + + public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) + { + if (data == null || data.Length == 0) + return null; + var att = new IosAttachment(name ?? "attachment"); + att.Filename = name ?? "attachment"; + att.MimeType = mimeType ?? "application/octet-stream"; + att.SetData(data); _attachments.Add(att); return att; } @@ -226,6 +243,7 @@ static void AppendValue(StringBuilder sb, object value) sealed class IosAttachment : IAttachment { + string _path; string _text; string _dataBase64; bool _isBinary; @@ -243,6 +261,7 @@ public IosAttachment(string name) public void SetData(byte[] data) { + _path = null; if (data == null || data.Length == 0) { _text = ""; @@ -272,6 +291,15 @@ public void SetData(string text) { _text = text ?? ""; _dataBase64 = null; + _path = null; + _isBinary = false; + } + + public void SetFilePath(string path) + { + _path = path; + _text = null; + _dataBase64 = null; _isBinary = false; } @@ -282,13 +310,21 @@ public string ToJsonObject() Append("name", Name); sb.Append(','); Append("fileName", Filename); sb.Append(','); Append("mimeType", MimeType); sb.Append(','); - if (_isBinary && !string.IsNullOrEmpty(_dataBase64)) + if (!string.IsNullOrEmpty(_path)) + { + Append("path", _path); + } + else if (_isBinary && !string.IsNullOrEmpty(_dataBase64)) + { + Append("dataBase64", _dataBase64); + } + else if (!string.IsNullOrEmpty(_dataBase64)) { Append("dataBase64", _dataBase64); } else { - Append("text", _text ?? ""); + Append("dataBase64", Convert.ToBase64String(Encoding.UTF8.GetBytes(_text ?? ""))); } sb.Append('}'); return sb.ToString(); diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeActionCatalog.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeActionCatalog.cs index 2c2f80d..d05922c 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeActionCatalog.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeActionCatalog.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text; using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Feedback; using Bugsee.Contracts.Lifecycle; @@ -377,13 +378,10 @@ public override void OnBeforeReportCreated(IReport report, bool isTerminating, A if (report != null) { report.Summary = "[Sample] " + (report.Summary ?? ""); - var att = report.CreateAndAddAttachment("sample-note.txt"); - if (att != null) - { - att.Filename = "sample-note.txt"; - att.MimeType = "text/plain"; - att.SetData("Attached by MiniGame SampleReportHandler"); - } + report.AddAttachmentBytes( + Encoding.UTF8.GetBytes("Attached by MiniGame SampleReportHandler"), + "sample-note.txt", + "text/plain"); } } catch (Exception ex) diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs new file mode 100644 index 0000000..fc27f60 --- /dev/null +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -0,0 +1,202 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class ReportPathSourceTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string ExtractMethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing method: " + signature); + int brace = source.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) + return source.Substring(start, i - start + 1); + } + } + + Assert.Fail("Unclosed method body: " + signature); + return null; + } + + [Test] + public void Ios_apply_report_dict_uses_path_and_bytes_attachments() + { + string mm = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + Assert.That(mm, Does.Not.Contain("createAndAddAttachmentWithName")); + + string apply = ExtractMethodBody(mm, "static void BugseeUnityApplyReportDict"); + Assert.That(apply, Does.Contain("path")); + Assert.That(apply, Does.Contain("dataBase64")); + Assert.That(apply, Does.Contain("addAttachmentWithFilePath:name:mimeType:move:")); + Assert.That(apply, Does.Contain("addAttachmentWithData:name:mimeType:")); + Assert.That(apply, Does.Contain("move:NO")); + Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); + } + + [Test] + public void Ios_forwardReport_terminates_before_report_callback() + { + string mm = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + string forward = ExtractMethodBody( + mm, + "- (void)forwardReport:(id)report"); + int terminating = forward.IndexOf("isTerminating", StringComparison.Ordinal); + int reportCb = forward.IndexOf("gReportCb", StringComparison.Ordinal); + Assert.That(terminating, Is.GreaterThanOrEqualTo(0)); + Assert.That(reportCb, Is.GreaterThan(terminating)); + + string terminatingBranch = ExtractMethodBody(forward, "if (isTerminating)"); + Assert.That(terminatingBranch, Does.Contain("completion()")); + Assert.That(terminatingBranch, Does.Not.Contain("gReportCb")); + } + + [Test] + public void Ios_on_report_skips_csharp_when_terminating() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosNativeCallbacks.cs")); + string onReport = ExtractMethodBody(ios, "static void OnReport(long requestId, int phase, int isTerminating, string json)"); + string terminatingBranch = ExtractMethodBody(onReport, "if (isTerminating != 0)"); + Assert.That(terminatingBranch, Does.Contain("_bugsee_complete_report")); + Assert.That(terminatingBranch, Does.Not.Contain("_reportHandler")); + Assert.That(terminatingBranch, Does.Not.Contain("OnBeforeReportCreated")); + Assert.That(terminatingBranch, Does.Not.Contain("OnAfterReportCreated")); + } + + [Test] + public void Android_report_handler_proxy_skips_handler_when_terminating() + { + string proxies = File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs")); + string invoke = ExtractMethodBody(proxies, "void Invoke(AndroidJavaObject report, bool isTerminating"); + string terminatingBranch = ExtractMethodBody(invoke, "if (isTerminating)"); + Assert.That(terminatingBranch, Does.Contain("completion?.Call(\"run\")")); + Assert.That(terminatingBranch, Does.Not.Contain("_handler")); + Assert.That(terminatingBranch, Does.Not.Contain("OnBeforeReportCreated")); + Assert.That(terminatingBranch, Does.Not.Contain("OnAfterReportCreated")); + } + + [Test] + public void Android_unity_wrapper_terminates_without_csharp_hop() + { + string java = File.ReadAllText(RepoFile("Plugins/Android/UnityWrapperProvider.java")); + string before = ExtractMethodBody(java, "public void onBeforeReportCreated(Report report, boolean isTerminating, Runnable completion)"); + string beforeTerm = ExtractMethodBody(before, "if (isTerminating)"); + Assert.That(beforeTerm, Does.Contain("completion.run()")); + Assert.That(beforeTerm, Does.Not.Contain("Unity")); + + string after = ExtractMethodBody(java, "public void onAfterReportCreated(Report report, boolean isTerminating, Runnable completion)"); + string afterTerm = ExtractMethodBody(after, "if (isTerminating)"); + Assert.That(afterTerm, Does.Contain("completion.run()")); + } + + [Test] + public void Bridges_enforce_attribute_and_user_identifier_policy() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + string androidSetAttr = ExtractMethodBody(android, "public void SetAttribute(string key, object value)"); + int evaluate = androidSetAttr.IndexOf("AttributePolicy.Evaluate", StringComparison.Ordinal); + int nativeSet = androidSetAttr.IndexOf("CallStatic(\"setAttribute\"", StringComparison.Ordinal); + int readBack = androidSetAttr.IndexOf("GetAttribute(key)", StringComparison.Ordinal); + Assert.That(evaluate, Is.GreaterThanOrEqualTo(0)); + Assert.That(nativeSet, Is.GreaterThan(evaluate)); + Assert.That(readBack, Is.GreaterThan(nativeSet)); + Assert.That(androidSetAttr, Does.Contain("attribute '")); + Assert.That(androidSetAttr, Does.Contain("' was dropped")); + + string androidSetUser = ExtractMethodBody(android, "public void SetUserIdentifier(string userIdentifier)"); + Assert.That(androidSetUser, Does.Contain("UserIdentifierPolicy.ForSet")); + string androidGetUser = ExtractMethodBody(android, "public string GetUserIdentifier()"); + Assert.That(androidGetUser, Does.Contain("UserIdentifierPolicy.ForGet")); + + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string iosSetAttr = ExtractMethodBody(ios, "public void SetAttribute(string key, object value)"); + evaluate = iosSetAttr.IndexOf("AttributePolicy.Evaluate", StringComparison.Ordinal); + nativeSet = iosSetAttr.IndexOf("_bugsee_set_attribute", StringComparison.Ordinal); + readBack = iosSetAttr.IndexOf("GetAttribute(key)", StringComparison.Ordinal); + Assert.That(evaluate, Is.GreaterThanOrEqualTo(0)); + Assert.That(nativeSet, Is.GreaterThan(evaluate)); + Assert.That(readBack, Is.GreaterThan(nativeSet)); + Assert.That(iosSetAttr, Does.Contain("attribute '")); + Assert.That(iosSetAttr, Does.Contain("' was dropped")); + + string iosSetUser = ExtractMethodBody(ios, "public void SetUserIdentifier(string userIdentifier)"); + Assert.That(iosSetUser, Does.Contain("UserIdentifierPolicy.ForSet")); + string iosGetUser = ExtractMethodBody(ios, "public string GetUserIdentifier()"); + Assert.That(iosGetUser, Does.Contain("UserIdentifierPolicy.ForGet")); + } + + [Test] + public void Report_severity_getters_use_try_from_wire_without_throw() + { + string androidReport = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidReport.cs")); + string androidGet = ExtractMethodBody( + androidReport, + "using (var s = _report.Call(\"getSeverity\"))"); + Assert.That(androidGet, Does.Contain("IssueSeverityWire.TryFromWire")); + Assert.That(androidGet, Does.Contain("(IssueSeverity?)null")); + Assert.That(androidGet, Does.Not.Contain("FromIntValue")); + Assert.That(androidGet, Does.Not.Contain("throw")); + + string iosReport = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")); + Assert.That( + iosReport, + Does.Contain("get => IssueSeverityWire.TryFromWire(_dto.severity, out var severity) ? severity : (IssueSeverity?)null;")); + Assert.That(iosReport, Does.Not.Contain("FromIntValue")); + } + + [Test] + public void Android_report_uses_path_and_bytes_attachments() + { + string report = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidReport.cs")); + string addFile = ExtractMethodBody(report, "public IAttachment AddAttachmentFile(string path, string name, string mimeType)"); + Assert.That(addFile, Does.Contain("string.IsNullOrEmpty(path)")); + Assert.That(addFile, Does.Contain("addAttachment\", file, name, mimeType, false")); + Assert.That(addFile, Does.Contain("att == null ? null")); + Assert.That(addFile, Does.Not.Contain("createAndAddAttachment")); + + string addBytes = ExtractMethodBody(report, "public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType)"); + Assert.That(addBytes, Does.Contain("addAttachment\", data, name, mimeType")); + Assert.That(addBytes, Does.Contain("att == null ? null")); + Assert.That(addBytes, Does.Not.Contain("createAndAddAttachment")); + + Assert.That(report, Does.Not.Contain("CreateAndAddAttachment")); + } + + [Test] + public void IReport_contract_exposes_path_and_bytes_attachments() + { + string contract = File.ReadAllText(RepoFile("Runtime/Contracts/Reporting/IReport.cs")); + Assert.That( + contract, + Does.Contain("IAttachment AddAttachmentFile(string path, string name, string mimeType);")); + Assert.That( + contract, + Does.Contain("IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType);")); + Assert.That(contract, Does.Not.Contain("CreateAndAddAttachment")); + Assert.That(contract, Does.Contain("IssueSeverity?")); + } + } +} From c959e9dd9db46630e63018c74678d4e3801e6ebe Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:32:16 +0500 Subject: [PATCH 044/157] Snapshot iOS attachment files at add time and honor fileName on apply. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityCallbacks.mm | 18 +++++++++-- Runtime/Platform/Android/AndroidReport.cs | 9 ++++-- Runtime/Platform/IOS/IosReport.cs | 31 +++++++++++++++++-- Tests~/WrapperPolicy/ReportPathSourceTests.cs | 21 +++++++++++-- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index b413c5e..5034547 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -271,8 +271,15 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar id pathVal = att[@"path"]; if ([pathVal isKindOfClass:[NSString class]] && [(NSString *)pathVal length] > 0) { id reportObj = (id)report; + id created = nil; if ([reportObj respondsToSelector:@selector(addAttachmentWithFilePath:name:mimeType:move:)]) { - [reportObj addAttachmentWithFilePath:(NSString *)pathVal name:name mimeType:mime move:NO]; + created = [reportObj addAttachmentWithFilePath:(NSString *)pathVal name:name mimeType:mime move:YES]; + } + if (created) { + id fileName = att[@"fileName"]; + if ([fileName isKindOfClass:[NSString class]] && [(NSString *)fileName length] > 0) { + created.fileName = fileName; + } } continue; } @@ -281,8 +288,15 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar NSData *decoded = [[NSData alloc] initWithBase64EncodedString:(NSString *)b64 options:0]; if (decoded) { id reportObj = (id)report; + id created = nil; if ([reportObj respondsToSelector:@selector(addAttachmentWithData:name:mimeType:)]) { - [reportObj addAttachmentWithData:decoded name:name mimeType:mime]; + created = [reportObj addAttachmentWithData:decoded name:name mimeType:mime]; + } + if (created) { + id fileName = att[@"fileName"]; + if ([fileName isKindOfClass:[NSString class]] && [(NSString *)fileName length] > 0) { + created.fileName = fileName; + } } } } diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index b50842f..308d221 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -132,16 +132,21 @@ public IAttachment AddAttachmentFile(string path, string name, string mimeType) if (string.IsNullOrEmpty(path)) return null; + var attachmentName = string.IsNullOrEmpty(name) ? "attachment" : name; using (var file = new AndroidJavaObject("java.io.File", path)) { - var att = _report.Call("addAttachment", file, name, mimeType, false); + var att = _report.Call("addAttachment", file, attachmentName, mimeType, false); return att == null ? null : new AndroidAttachment(att); } } public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) { - var att = _report.Call("addAttachment", data, name, mimeType); + if (data == null || data.Length == 0) + return null; + + var attachmentName = string.IsNullOrEmpty(name) ? "attachment" : name; + var att = _report.Call("addAttachment", data, attachmentName, mimeType); return att == null ? null : new AndroidAttachment(att); } diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index 7e7016c..1a2e0d8 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -1,6 +1,7 @@ #if UNITY_IOS && !UNITY_EDITOR using System; using System.Collections.Generic; +using System.IO; using System.Text; using Bugsee.Contracts.Options; using Bugsee.Contracts.Reporting; @@ -138,12 +139,14 @@ public void SetLabels(IEnumerable labels) public IAttachment AddAttachmentFile(string path, string name, string mimeType) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + + var snapshotPath = SnapshotAttachmentFile(path); var att = new IosAttachment(name ?? "attachment"); att.Filename = name ?? "attachment"; att.MimeType = mimeType ?? "application/octet-stream"; - att.SetFilePath(path); + att.SetFilePath(snapshotPath); _attachments.Add(att); return att; } @@ -155,11 +158,23 @@ public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) var att = new IosAttachment(name ?? "attachment"); att.Filename = name ?? "attachment"; att.MimeType = mimeType ?? "application/octet-stream"; - att.SetData(data); + att.SetAttachmentBytes(data); _attachments.Add(att); return att; } + static string SnapshotAttachmentFile(string sourcePath) + { + var dir = Path.Combine(Path.GetTempPath(), "bugsee-unity-report-attachments"); + Directory.CreateDirectory(dir); + var ext = Path.GetExtension(sourcePath); + if (string.IsNullOrEmpty(ext)) + ext = ".bin"; + var dest = Path.Combine(dir, Guid.NewGuid().ToString("N") + ext); + File.Copy(sourcePath, dest, true); + return dest; + } + public void ClearAttachments() => _attachments.Clear(); public string ToResultJson() @@ -303,6 +318,16 @@ public void SetFilePath(string path) _isBinary = false; } + public void SetAttachmentBytes(byte[] data) + { + _path = null; + _text = null; + _isBinary = true; + _dataBase64 = Convert.ToBase64String(data); + if (string.IsNullOrEmpty(MimeType) || MimeType == "text/plain") + MimeType = "application/octet-stream"; + } + public string ToJsonObject() { var sb = new StringBuilder(128); diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index fc27f60..4fe3179 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -53,7 +53,8 @@ public void Ios_apply_report_dict_uses_path_and_bytes_attachments() Assert.That(apply, Does.Contain("dataBase64")); Assert.That(apply, Does.Contain("addAttachmentWithFilePath:name:mimeType:move:")); Assert.That(apply, Does.Contain("addAttachmentWithData:name:mimeType:")); - Assert.That(apply, Does.Contain("move:NO")); + Assert.That(apply, Does.Contain("move:YES")); + Assert.That(apply, Does.Contain("fileName")); Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); } @@ -173,18 +174,32 @@ public void Android_report_uses_path_and_bytes_attachments() string report = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidReport.cs")); string addFile = ExtractMethodBody(report, "public IAttachment AddAttachmentFile(string path, string name, string mimeType)"); Assert.That(addFile, Does.Contain("string.IsNullOrEmpty(path)")); - Assert.That(addFile, Does.Contain("addAttachment\", file, name, mimeType, false")); + Assert.That(addFile, Does.Contain("attachmentName")); + Assert.That(addFile, Does.Contain("addAttachment\", file, attachmentName, mimeType, false")); Assert.That(addFile, Does.Contain("att == null ? null")); Assert.That(addFile, Does.Not.Contain("createAndAddAttachment")); string addBytes = ExtractMethodBody(report, "public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType)"); - Assert.That(addBytes, Does.Contain("addAttachment\", data, name, mimeType")); + Assert.That(addBytes, Does.Contain("addAttachment\", data, attachmentName, mimeType")); Assert.That(addBytes, Does.Contain("att == null ? null")); Assert.That(addBytes, Does.Not.Contain("createAndAddAttachment")); Assert.That(report, Does.Not.Contain("CreateAndAddAttachment")); } + [Test] + public void Ios_report_snapshots_files_and_base64_bytes() + { + string report = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")); + string addFile = ExtractMethodBody(report, "public IAttachment AddAttachmentFile(string path, string name, string mimeType)"); + Assert.That(addFile, Does.Contain("SnapshotAttachmentFile")); + Assert.That(addFile, Does.Contain("File.Exists(path)")); + + string addBytes = ExtractMethodBody(report, "public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType)"); + Assert.That(addBytes, Does.Contain("SetAttachmentBytes")); + Assert.That(addBytes, Does.Not.Contain("SetData(data)")); + } + [Test] public void IReport_contract_exposes_path_and_bytes_attachments() { From 698c632ce87f97733a08d9567d988e0d05f3f2b4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:40:19 +0500 Subject: [PATCH 045/157] Add delete-data, create-report, and breadcrumb, and buffer iOS network events until launch. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 173 +++++++++++ Plugins/iOS/BugseeUnityCallbacks.mm | 49 +++ Runtime/Bugsee.cs | 14 + Runtime/Platform/Android/AndroidBridge.cs | 65 ++++ Runtime/Platform/Editor/EditorBridge.cs | 16 + Runtime/Platform/IBugseeNativeBridge.cs | 8 + Runtime/Platform/IOS/IOSBridge.cs | 285 +++++++++++++++++- Runtime/Platform/IOS/IosNativeCallbacks.cs | 2 +- Runtime/WrapperPolicy/BreadcrumbLevelMap.cs | 9 + Runtime/WrapperPolicy/ChannelSubmit.cs | 9 + Runtime/WrapperPolicy/ChannelSubmit.cs.meta | 11 + .../WrapperPolicy/NetworkEventLaunchBuffer.cs | 79 +++++ .../NetworkEventLaunchBuffer.cs.meta | 11 + Tests~/WrapperPolicy/ChannelSubmitTests.cs | 19 ++ .../NetworkEventLaunchBufferTests.cs | 60 ++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 260 ++++++++++++++++ 16 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 Runtime/WrapperPolicy/ChannelSubmit.cs create mode 100644 Runtime/WrapperPolicy/ChannelSubmit.cs.meta create mode 100644 Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs create mode 100644 Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs.meta create mode 100644 Tests~/WrapperPolicy/ChannelSubmitTests.cs create mode 100644 Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs create mode 100644 Tests~/WrapperPolicy/PublicApiSourceTests.cs diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 4319e4a..0e0bbd7 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -5,9 +5,11 @@ #if __has_include() #import +#import #define BUGSEE_IOS_SDK 1 #elif __has_include("Bugsee/Bugsee.h") #import "Bugsee/Bugsee.h" +#import "Bugsee/BGSContracts.h" #define BUGSEE_IOS_SDK 1 #else #define BUGSEE_IOS_SDK 0 @@ -42,6 +44,126 @@ static void BugseeRunOnMain(dispatch_block_t block) } } +static id gOpenReport; + +static void BugseeBridgeApplyReportDict(id report, NSDictionary *d) +{ + if (![d isKindOfClass:[NSDictionary class]] || !report) { + return; + } + + id summary = d[@"summary"]; + if ([summary isKindOfClass:[NSString class]]) { + report.summary = summary; + } + id desc = d[@"description"]; + if ([desc isKindOfClass:[NSString class]]) { + report.reportDescription = desc; + } + id email = d[@"email"]; + if ([email isKindOfClass:[NSString class]]) { + report.email = email; + } + id sev = d[@"severity"]; + if ([sev respondsToSelector:@selector(integerValue)]) { + report.severity = (BugseeSeverityLevel)[sev integerValue]; + } + + id labels = d[@"labels"]; + if ([labels isKindOfClass:[NSArray class]]) { + [report clearLabels]; + for (id label in (NSArray *)labels) { + if ([label isKindOfClass:[NSString class]]) { + [report addLabel:label]; + } + } + } + + id attrs = d[@"attributes"]; + if ([attrs isKindOfClass:[NSDictionary class]]) { + [report clearAllAttributes]; + [(NSDictionary *)attrs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if ([key isKindOfClass:[NSString class]]) { + [report setAttribute:obj forName:key]; + } + }]; + } + + id attachments = d[@"attachments"]; + if ([attachments isKindOfClass:[NSArray class]]) { + id reportObj = (id)report; + if ([reportObj respondsToSelector:@selector(clearAttachments)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [reportObj performSelector:@selector(clearAttachments)]; +#pragma clang diagnostic pop + } + for (id item in (NSArray *)attachments) { + if (![item isKindOfClass:[NSDictionary class]]) { + continue; + } + NSDictionary *att = (NSDictionary *)item; + NSString *name = [att[@"name"] isKindOfClass:[NSString class]] ? att[@"name"] : @"attachment"; + NSString *mime = [att[@"mimeType"] isKindOfClass:[NSString class]] ? att[@"mimeType"] : @"text/plain"; + id filePath = att[@"filePath"]; + if ([filePath isKindOfClass:[NSString class]] && [(NSString *)filePath length] > 0 + && [reportObj respondsToSelector:@selector(addAttachmentWithFilePath:name:mimeType:move:)]) { + [reportObj addAttachmentWithFilePath:filePath name:name mimeType:mime move:NO]; + continue; + } + NSData *data = nil; + id b64 = att[@"dataBase64"]; + if ([b64 isKindOfClass:[NSString class]] && [(NSString *)b64 length] > 0) { + data = [[NSData alloc] initWithBase64EncodedString:(NSString *)b64 options:0]; + } + id text = att[@"text"]; + if (!data && [text isKindOfClass:[NSString class]]) { + data = [(NSString *)text dataUsingEncoding:NSUTF8StringEncoding]; + } + if (!data) { + data = [NSData data]; + } + if ([reportObj respondsToSelector:@selector(addAttachmentWithData:name:mimeType:)]) { + [reportObj addAttachmentWithData:data name:name mimeType:mime]; + } + } + } +} + +static NSDictionary *BugseeBridgeReportToDict(id report) +{ + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + d[@"id"] = report.reportId ?: @""; + d[@"summary"] = report.summary ?: @""; + d[@"description"] = report.reportDescription ?: @""; + d[@"email"] = report.email ?: @""; + d[@"severity"] = @(report.severity); + d[@"labels"] = report.labels ? [report.labels copy] : @[]; + d[@"attributes"] = report.attributes ? [report.attributes copy] : @{}; + if ([(id)report respondsToSelector:@selector(type)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + id typeVal = [(id)report performSelector:@selector(type)]; +#pragma clang diagnostic pop + if ([typeVal isKindOfClass:[NSString class]]) { + d[@"type"] = typeVal; + } + } + return d; +} + +static NSString *BugseeJsonStringFromObject(id obj) +{ + if (!obj || ![NSJSONSerialization isValidJSONObject:obj]) { + return @"{}"; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:0 error:nil]; + if (!data) { + return @"{}"; + } + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}"; +} + extern "C" { void _bugsee_launch(const char *appToken, const char *optionsJson) @@ -355,6 +477,52 @@ void _bugsee_free(char *ptr) } } +void _bugsee_delete_collected_data(void) +{ + BugseeRunOnMain(^{ + [Bugsee deleteCollectedDataOnDevice]; + }); +} + +char *_bugsee_create_report(void) +{ + if (gOpenReport) { + return NULL; + } + id report = [Bugsee createReport]; + if (!report) { + return NULL; + } + gOpenReport = report; + return BugseeCopyUTF8(BugseeJsonStringFromObject(BugseeBridgeReportToDict(report))); +} + +void _bugsee_apply_open_report(const char *reportJson) +{ + if (!gOpenReport || !reportJson) { + return; + } + NSDictionary *dict = BugseeDeserializeJson(reportJson); + if (![dict isKindOfClass:[NSDictionary class]]) { + return; + } + BugseeBridgeApplyReportDict(gOpenReport, dict); +} + +void _bugsee_upload_open_report(void) +{ + if (!gOpenReport) { + return; + } + [Bugsee uploadReport:gOpenReport completion:nil]; + gOpenReport = nil; +} + +void _bugsee_release_open_report(void) +{ + gOpenReport = nil; +} + } // extern "C" #else // !BUGSEE_IOS_SDK @@ -393,6 +561,11 @@ void _bugsee_feedback_show(void) {} void _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { (void)propertyName; (void)propertyValue; } char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } +void _bugsee_delete_collected_data(void) {} +char *_bugsee_create_report(void) { return NULL; } +void _bugsee_apply_open_report(const char *reportJson) { (void)reportJson; } +void _bugsee_upload_open_report(void) {} +void _bugsee_release_open_report(void) {} } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index fcf9c4f..99496ca 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -421,6 +421,53 @@ void _bugsee_channel_log(const char *message, int level, int source) source:(BGSLogEventSource)source]; } +void _bugsee_channel_network(const char *eventJson, int requiresFiltering) +{ + id channel = gChannel; + if (!channel || !eventJson) { + return; + } + if (![channel respondsToSelector:@selector(addNetworkEvent:requiresFiltering:)]) { + return; + } + NSDictionary *dict = BugseeUnityParseJson(eventJson); + if (![dict isKindOfClass:[NSDictionary class]]) { + return; + } + BugseeNetworkEvent *event = [BugseeNetworkEvent new]; + id eventId = dict[@"id"]; + if ([eventId isKindOfClass:[NSString class]]) { + event.ID = eventId; + } + BugseeUnityApplyNetworkDict(event, dict); + [channel addNetworkEvent:event requiresFiltering:requiresFiltering != 0]; +} + +void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel) +{ + id channel = gChannel; + if (!channel) { + return; + } + if (![channel respondsToSelector:@selector(addBreadcrumb:)]) { + return; + } + NSString *categoryText = category ? [NSString stringWithUTF8String:category] : @""; + NSString *messageText = message ? [NSString stringWithUTF8String:message] : @""; + if (!categoryText) { + categoryText = @""; + } + if (!messageText) { + messageText = @""; + } + id breadcrumb = [BugseeBreadcrumb breadcrumbWithCategory:categoryText message:messageText]; + if (!breadcrumb) { + return; + } + breadcrumb.level = (BugseeLogLevel)iosLevel; + [channel addBreadcrumb:breadcrumb]; +} + void _bugsee_ensure_wrapper(const char *version, const char *build) { if (version) gWrapperVersion = [NSString stringWithUTF8String:version]; @@ -607,6 +654,8 @@ int _bugsee_test_wrapper_installed(void) void _bugsee_register_unity_callbacks(void *a, void *b, void *c) { (void)a; (void)b; (void)c; } void _bugsee_clear_wrapper_channel(void) {} void _bugsee_channel_log(const char *message, int level, int source) { (void)message; (void)level; (void)source; } +void _bugsee_channel_network(const char *eventJson, int requiresFiltering) { (void)eventJson; (void)requiresFiltering; } +void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel) { (void)category; (void)message; (void)iosLevel; } void _bugsee_ensure_wrapper(const char *version, const char *build) { (void)version; (void)build; } void _bugsee_set_wrapper_context(const char *json) { (void)json; } void _bugsee_set_network_filter_enabled(int enabled) { (void)enabled; } diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 20a67fe..8f98a3f 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -85,6 +85,14 @@ public static void Log(string message, LogLevel level = LogLevel.Info) => public static void AddNetworkEvent(INetworkEvent networkEvent) => Bridge.AddNetworkEvent(networkEvent); + public static void DeleteCollectedDataOnDevice() => + Bridge.DeleteCollectedDataOnDevice(); + + public static IReport CreateReport() => Bridge.CreateReport(); + + public static void AddBreadcrumb(string category, string message, string levelName) => + Bridge.AddBreadcrumb(category, message, levelName); + public static IBugseeExchangeFactory GetExchangeFactory() => Bridge.GetExchangeFactory(); @@ -119,6 +127,9 @@ public static void Upload( IList labels = null) => Bridge.Upload(summary, description, severity, labels); + /// Upload a report created with . + public static void Upload(IReport report) => Bridge.UploadReport(report); + public static void SetUserIdentifier(string userIdentifier) => Bridge.SetUserIdentifier(userIdentifier); @@ -167,6 +178,9 @@ public static void SetLifecycleEventListener(ILifecycleEventListener listener) /// Called from native wrapper / lifecycle proxy. Not for app use. internal static void HandleNativeLifecycle(string eventType, object data) { + try { Bridge.NotifyLifecycle(eventType); } + catch (Exception ex) { Debug.LogException(ex); } + try { LifecycleEvent?.Invoke(eventType, data); } catch (Exception ex) { Debug.LogException(ex); } diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 08cd84f..874a172 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -1,6 +1,7 @@ #if UNITY_ANDROID && !UNITY_EDITOR using System; using System.Collections.Generic; +using System.Reflection; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Feedback; @@ -437,6 +438,70 @@ public void SetLifecycleEventListener(ILifecycleEventListener listener) InstallDefaultListeners(); } + public void NotifyLifecycle(string eventType) { } + + public void DeleteCollectedDataOnDevice() + { + if (GetLaunched()) + return; + _bugsee.CallStatic("deleteCollectedDataOnDevice"); + } + + public IReport CreateReport() + { + var javaReport = _bugsee.CallStatic("createReport"); + return new AndroidReport(javaReport); + } + + public void UploadReport(IReport report) + { + if (report == null) + throw new ArgumentNullException(nameof(report)); + var javaReport = TryGetAndroidReportObject(report); + if (javaReport == null) + throw new ArgumentException("Report was not created by Bugsee.CreateReport on Android.", nameof(report)); + _bugsee.CallStatic("upload", javaReport); + } + + static AndroidJavaObject TryGetAndroidReportObject(IReport report) + { + if (!(report is AndroidReport androidReport)) + return null; + var field = typeof(AndroidReport).GetField( + "_report", + BindingFlags.Instance | BindingFlags.NonPublic); + return field?.GetValue(androidReport) as AndroidJavaObject; + } + + public void AddBreadcrumb(string category, string message, string levelName) + { + var level = BreadcrumbLevelMap.ParseOrThrow(levelName); + if (GetExchangeFactory() == null) + return; + + using (var levelClass = new AndroidJavaClass( + "com.bugsee.library.contracts.exchange.Breadcrumb$Level")) + using (var def = levelClass.CallStatic("fromRawValue", (sbyte)1)) + using (var javaLevel = levelClass.CallStatic( + "fromRawValue", + (sbyte)BreadcrumbLevelMap.ToAndroid(level), + def)) + using (var javaFactory = _bugsee.CallStatic("getExchangeFactory")) + { + if (javaFactory == null) + return; + var breadcrumb = javaFactory.Call( + "createBreadcrumb", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + category ?? "", + message ?? "", + javaLevel); + if (breadcrumb == null) + return; + _bugsee.CallStatic("addBreadcrumb", breadcrumb); + } + } + void InstallDefaultListeners() { if (_lifecycleProxy == null) diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index a682870..09ed831 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -8,6 +8,7 @@ using Bugsee.Contracts.Reporting; using Bugsee.Internal; using Bugsee.Platform; +using Bugsee.WrapperPolicy; using UnityEngine; namespace Bugsee.Platform.EditorStub @@ -62,6 +63,21 @@ public void SetLogEventFilter(EventFilter filter) { } public void SetBreadcrumbFilter(EventFilter filter) { } public void SetReportHandler(IReportHandler handler) { } public void SetLifecycleEventListener(ILifecycleEventListener listener) { } + public void NotifyLifecycle(string eventType) { } + + public void DeleteCollectedDataOnDevice() { } + + public IReport CreateReport() => + throw new NotSupportedException("CreateReport is not supported in the Unity Editor."); + + public void AddBreadcrumb(string category, string message, string levelName) + { + BreadcrumbLevelMap.ParseOrThrow(levelName); + } + + public void UploadReport(IReport report) => + throw new NotSupportedException("Upload(IReport) is not supported in the Unity Editor."); + public void EnsureWrapperRegistered() { } sealed class StubAppearance : IAppearance diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index 69e7f30..e9b22ef 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -70,6 +70,14 @@ public interface IBugseeNativeBridge void SetReportHandler(IReportHandler handler); void SetLifecycleEventListener(ILifecycleEventListener listener); + void DeleteCollectedDataOnDevice(); + IReport CreateReport(); + void AddBreadcrumb(string category, string message, string levelName); + void UploadReport(IReport report); + + /// Native lifecycle fan-out for bridge-side policy (iOS network buffer). + void NotifyLifecycle(string eventType); + /// Register the Unity BugseeWrapper implementation with the native SDK. void EnsureWrapperRegistered(); } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index c03331b..e4a154c 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -9,9 +9,9 @@ using Bugsee.Contracts.Options; using Bugsee.WrapperPolicy; using Bugsee.Contracts.Reporting; +using UnityEngine; using Bugsee.Internal; using Bugsee.Platform; -using UnityEngine; namespace Bugsee.Platform.IOS { @@ -26,9 +26,17 @@ sealed class IOSBridge : IBugseeNativeBridge bool _launched; bool _blackout; IosAppearance _appearance; + IosReport _openReport; + IosLiveReport _openReportHandle; + readonly NetworkEventLaunchBuffer _networkLaunchBuffer = new NetworkEventLaunchBuffer(); public bool IsSupported => true; + public IOSBridge() + { + _networkLaunchBuffer.SetSubmitHandler(SubmitNetworkEventToChannel); + } + public IFeedback Feedback { get; } = new IosFeedback(); public void EnsureWrapperRegistered() => IosNativeCallbacks.EnsureWrapper(); @@ -39,6 +47,7 @@ public void Launch(string appToken, IDictionary options) if (!MainThreadDispatcher.RunSyncLifecycle(() => { EnsureWrapperRegistered(); + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options))); _launched = true; ExceptionPipeline.Install(this, options); @@ -53,6 +62,7 @@ public void Relaunch(IDictionary options) { if (!MainThreadDispatcher.RunSyncLifecycle(() => { + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options))); ExceptionPipeline.Install(this, options); HostLogForwarder.InstallOnce(this); @@ -71,6 +81,14 @@ public void Stop(Action completion = null) _bugsee_clear_wrapper_channel(); _bugsee_stop(); _launched = false; + if (_openReport != null) + { + ApplyOpenReportToNative(); + _bugsee_release_open_report(); + } + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); completion?.Invoke(); })) { @@ -105,7 +123,66 @@ public void ChannelLog(string message, LogLevel level) public void AddNetworkEvent(INetworkEvent networkEvent) { - // iOS wrapper-channel network export lands in a follow-up task. + _networkLaunchBuffer.Enqueue(networkEvent); + } + + public void NotifyLifecycle(string eventType) + { + if (eventType == LifecycleEvents.Launched) + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Launched); + else if (eventType == LifecycleEvents.Stopped) + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + } + + public void DeleteCollectedDataOnDevice() => _bugsee_delete_collected_data(); + + public IReport CreateReport() + { + if (_openReport != null) + throw new InvalidOperationException("a report is already open"); + var json = ConsumeNativeString(_bugsee_create_report()); + if (string.IsNullOrEmpty(json)) + throw new InvalidOperationException("CreateReport failed."); + var dto = JsonUtility.FromJson(IosJsonNormalize.NormalizeReport(json)); + _openReport = new IosReport(dto); + _openReportHandle = new IosLiveReport(_openReport, this); + return _openReportHandle; + } + + public void UploadReport(IReport report) + { + if (!ReferenceEquals(report, _openReportHandle)) + throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); + ApplyOpenReportToNative(); + _bugsee_upload_open_report(); + _openReport = null; + _openReportHandle = null; + } + + internal void ApplyOpenReportToNative() + { + if (_openReport == null) + return; + _bugsee_apply_open_report(_openReport.ToResultJson()); + } + + internal void ReleaseOpenReportFromLiveHandle(IosLiveReport handle) + { + if (!ReferenceEquals(handle, _openReportHandle)) + return; + ApplyOpenReportToNative(); + _bugsee_release_open_report(); + _openReport = null; + _openReportHandle = null; + } + + public void AddBreadcrumb(string category, string message, string levelName) + { + var level = BreadcrumbLevelMap.ParseOrThrow(levelName); + _bugsee_channel_breadcrumb( + category ?? "", + message ?? "", + BreadcrumbLevelMap.ToIos(level)); } public IBugseeExchangeFactory GetExchangeFactory() => IosExchangeFactory.Instance; @@ -208,6 +285,47 @@ public void SetLifecycleEventListener(ILifecycleEventListener listener) EnsureWrapperRegistered(); } + void SubmitNetworkEventToChannel(INetworkEvent networkEvent) + { + if (networkEvent == null) + return; + + var iosEvent = networkEvent as IosNetworkEvent; + if (iosEvent == null) + { + var factory = GetExchangeFactory(); + if (factory == null) + return; + iosEvent = (IosNetworkEvent)factory.CreateNetworkEvent( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + networkEvent.Stage, + string.IsNullOrEmpty(networkEvent.Id) ? null : networkEvent.Id, + networkEvent.Mechanism, + networkEvent.Method); + if (iosEvent == null) + return; + CopyNetworkFields(networkEvent, iosEvent); + } + + var requiresFiltering = ChannelSubmit.NetworkRequiresFiltering() ? 1 : 0; + var json = IosNativeCallbacks.ToNativeMapJson(iosEvent.ToResultJson(), "headers"); + _bugsee_channel_network(json, requiresFiltering); + } + + static void CopyNetworkFields(INetworkEvent source, INetworkEvent target) + { + if (source == null || target == null || ReferenceEquals(source, target)) + return; + target.Url = source.Url; + target.Body = source.Body; + target.Size = source.Size; + target.ResponseCode = source.ResponseCode; + target.StatusText = source.StatusText; + target.ErrorShortMessage = source.ErrorShortMessage; + target.ErrorDescription = source.ErrorDescription; + target.Headers = source.Headers; + } + static string ConsumeNativeString(IntPtr ptr) { if (ptr == IntPtr.Zero) return null; @@ -292,6 +410,13 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_end_blackout(); [DllImport("__Internal")] static extern void _bugsee_log(string message, int level); [DllImport("__Internal")] static extern void _bugsee_channel_log(string message, int level, int source); + [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); + [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); + [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); + [DllImport("__Internal")] static extern IntPtr _bugsee_create_report(); + [DllImport("__Internal")] static extern void _bugsee_apply_open_report(string reportJson); + [DllImport("__Internal")] static extern void _bugsee_upload_open_report(); + [DllImport("__Internal")] static extern void _bugsee_release_open_report(); [DllImport("__Internal")] static extern void _bugsee_trace(string name, string valueJson); [DllImport("__Internal")] static extern void _bugsee_event(string name, string paramsJson); [DllImport("__Internal")] static extern void _bugsee_logException(string name, string reason, bool handled); @@ -369,6 +494,162 @@ public string GetString(string propertyName) public IReadOnlyDictionary ToMap() => new Dictionary(_cache); } + + /// Live handle over the native open report created by . + internal sealed class IosLiveReport : IReport, IDisposable + { + readonly IosReport _inner; + readonly IOSBridge _owner; + + internal IosLiveReport(IosReport inner, IOSBridge owner) + { + _inner = inner; + _owner = owner; + } + + public void Dispose() => _owner.ReleaseOpenReportFromLiveHandle(this); + + public string Id => _inner.Id; + public IssueType Type => _inner.Type; + + public string Summary + { + get => _inner.Summary; + set { _inner.Summary = value; _owner.ApplyOpenReportToNative(); } + } + + public string Description + { + get => _inner.Description; + set { _inner.Description = value; _owner.ApplyOpenReportToNative(); } + } + + public string Email + { + get => _inner.Email; + set { _inner.Email = value; _owner.ApplyOpenReportToNative(); } + } + + public IssueSeverity? Severity + { + get => _inner.Severity; + set { _inner.Severity = value; _owner.ApplyOpenReportToNative(); } + } + + public IReadOnlyDictionary Attributes => _inner.Attributes; + + public object GetAttribute(string name) => _inner.GetAttribute(name); + + public void SetAttribute(string name, object value) + { + _inner.SetAttribute(name, value); + _owner.ApplyOpenReportToNative(); + } + + public void RemoveAttribute(string name) + { + _inner.RemoveAttribute(name); + _owner.ApplyOpenReportToNative(); + } + + public void ClearAllAttributes() + { + _inner.ClearAllAttributes(); + _owner.ApplyOpenReportToNative(); + } + + public IReadOnlyList Labels => _inner.Labels; + + public void AddLabel(string label) + { + _inner.AddLabel(label); + _owner.ApplyOpenReportToNative(); + } + + public void ClearLabels() + { + _inner.ClearLabels(); + _owner.ApplyOpenReportToNative(); + } + + public void SetLabels(IEnumerable labels) + { + _inner.SetLabels(labels); + _owner.ApplyOpenReportToNative(); + } + + public IReadOnlyList Attachments + { + get + { + var inner = _inner.Attachments; + var wrapped = new List(inner.Count); + for (var i = 0; i < inner.Count; i++) + { + if (inner[i] is IosAttachment iosAttachment) + wrapped.Add(new IosLiveAttachment(iosAttachment, _owner)); + else + wrapped.Add(inner[i]); + } + return wrapped; + } + } + + public IAttachment CreateAndAddAttachment(string name) + { + var att = (IosAttachment)_inner.CreateAndAddAttachment(name); + _owner.ApplyOpenReportToNative(); + return new IosLiveAttachment(att, _owner); + } + + public void ClearAttachments() + { + _inner.ClearAttachments(); + _owner.ApplyOpenReportToNative(); + } + } + + sealed class IosLiveAttachment : IAttachment + { + readonly IosAttachment _inner; + readonly IOSBridge _owner; + + internal IosLiveAttachment(IosAttachment inner, IOSBridge owner) + { + _inner = inner; + _owner = owner; + } + + public string Name + { + get => _inner.Name; + set { _inner.Name = value; _owner.ApplyOpenReportToNative(); } + } + + public string Filename + { + get => _inner.Filename; + set { _inner.Filename = value; _owner.ApplyOpenReportToNative(); } + } + + public string MimeType + { + get => _inner.MimeType; + set { _inner.MimeType = value; _owner.ApplyOpenReportToNative(); } + } + + public void SetData(byte[] data) + { + _inner.SetData(data); + _owner.ApplyOpenReportToNative(); + } + + public void SetData(string text) + { + _inner.SetData(text); + _owner.ApplyOpenReportToNative(); + } + } } sealed class IosExchangeFactory : IBugseeExchangeFactory diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 575a502..7c81371 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -319,7 +319,7 @@ static void OnLifecycle(string eventType, string dataJson) /// /// JsonUtility emits kv arrays; native Apply* expects object maps for headers/data. /// - static string ToNativeMapJson(string json, string field) + internal static string ToNativeMapJson(string json, string field) { if (string.IsNullOrEmpty(json)) return "{}"; var key = "\"" + field + "\":"; diff --git a/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs index 27ca7c5..9082410 100644 --- a/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs +++ b/Runtime/WrapperPolicy/BreadcrumbLevelMap.cs @@ -80,5 +80,14 @@ public static int ToIos(BreadcrumbLevelName level) throw new System.ArgumentOutOfRangeException(nameof(level), level, "Unknown breadcrumb level."); } } + + public static BreadcrumbLevelName ParseOrThrow(string levelName) + { + if (TryParse(levelName, out var level)) + return level; + throw new System.ArgumentException( + "breadcrumb level '" + levelName + "' is unknown", + nameof(levelName)); + } } } diff --git a/Runtime/WrapperPolicy/ChannelSubmit.cs b/Runtime/WrapperPolicy/ChannelSubmit.cs new file mode 100644 index 0000000..6321a36 --- /dev/null +++ b/Runtime/WrapperPolicy/ChannelSubmit.cs @@ -0,0 +1,9 @@ +namespace Bugsee.WrapperPolicy +{ + public static class ChannelSubmit + { + public static int LogSourceForHostDebug() => 98; + + public static bool NetworkRequiresFiltering() => true; + } +} diff --git a/Runtime/WrapperPolicy/ChannelSubmit.cs.meta b/Runtime/WrapperPolicy/ChannelSubmit.cs.meta new file mode 100644 index 0000000..ee3f6f5 --- /dev/null +++ b/Runtime/WrapperPolicy/ChannelSubmit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4d8e2f1a3b5476980e1f2a3b4c5d6e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs new file mode 100644 index 0000000..d7cc117 --- /dev/null +++ b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + public enum NetworkLaunchPhase + { + BeforeLaunched, + Launched, + Stopped + } + + /// + /// Buffers wrapper-channel network submits on iOS until lifecycle reaches Launched. + /// + public sealed class NetworkEventLaunchBuffer + { + readonly List _pending = new List(); + NetworkLaunchPhase _phase = NetworkLaunchPhase.BeforeLaunched; + Action _submit; + + public NetworkLaunchPhase Phase => _phase; + + public void SetSubmitHandler(Action submit) => _submit = submit; + + public void SetPhase(NetworkLaunchPhase phase) + { + if (phase == NetworkLaunchPhase.Launched) + { + if (_phase == NetworkLaunchPhase.Launched) + return; + _phase = NetworkLaunchPhase.Launched; + FlushPending(); + return; + } + + if (phase == NetworkLaunchPhase.Stopped) + { + _phase = NetworkLaunchPhase.Stopped; + _pending.Clear(); + return; + } + + if (phase == NetworkLaunchPhase.BeforeLaunched) + _phase = NetworkLaunchPhase.BeforeLaunched; + } + + public void Enqueue(T item) + { + if (item == null) + return; + + if (_phase == NetworkLaunchPhase.Stopped) + return; + + if (_phase == NetworkLaunchPhase.Launched) + { + _submit?.Invoke(item); + return; + } + + _pending.Add(item); + } + + void FlushPending() + { + if (_submit == null || _pending.Count == 0) + { + _pending.Clear(); + return; + } + + var copy = new List(_pending); + _pending.Clear(); + for (var i = 0; i < copy.Count; i++) + _submit(copy[i]); + } + } +} diff --git a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs.meta b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs.meta new file mode 100644 index 0000000..f612cc8 --- /dev/null +++ b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1e2d3c4b5a6978869504132a1b0c9d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/ChannelSubmitTests.cs b/Tests~/WrapperPolicy/ChannelSubmitTests.cs new file mode 100644 index 0000000..c00f9a9 --- /dev/null +++ b/Tests~/WrapperPolicy/ChannelSubmitTests.cs @@ -0,0 +1,19 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class ChannelSubmitTests + { + [Test] + public void LogSourceForHostDebug_is_custom_98() + { + Assert.That(ChannelSubmit.LogSourceForHostDebug(), Is.EqualTo(98)); + } + + [Test] + public void NetworkRequiresFiltering_is_true() + { + Assert.That(ChannelSubmit.NetworkRequiresFiltering(), Is.True); + } + } +} diff --git a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs new file mode 100644 index 0000000..b2df941 --- /dev/null +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class NetworkEventLaunchBufferTests + { + [Test] + public void Flush_preserves_insertion_order_after_launch() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.Enqueue(1); + buffer.Enqueue(2); + buffer.SetPhase(NetworkLaunchPhase.Launched); + + Assert.That(delivered, Is.EqualTo(new[] { 1, 2 })); + } + + [Test] + public void Stop_clears_pending_without_flush() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.Enqueue(1); + buffer.Enqueue(2); + buffer.SetPhase(NetworkLaunchPhase.Stopped); + buffer.SetPhase(NetworkLaunchPhase.Launched); + + Assert.That(delivered, Is.Empty); + } + + [Test] + public void Events_after_stop_are_not_delivered_until_a_new_launch_flush() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + buffer.Enqueue(10); + buffer.SetPhase(NetworkLaunchPhase.Launched); + Assert.That(delivered, Is.EqualTo(new[] { 10 })); + + delivered.Clear(); + buffer.SetPhase(NetworkLaunchPhase.Stopped); + buffer.Enqueue(20); + Assert.That(delivered, Is.Empty); + + buffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + buffer.Enqueue(30); + buffer.SetPhase(NetworkLaunchPhase.Launched); + Assert.That(delivered, Is.EqualTo(new[] { 30 })); + } + } +} diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs new file mode 100644 index 0000000..e363d2e --- /dev/null +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class PublicApiSourceTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string ExtractMethodBody(string source, string methodSignature) + { + int start = source.IndexOf(methodSignature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Method missing: " + methodSignature); + int brace = source.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) + return source.Substring(start, i - start + 1); + } + } + + throw new InvalidOperationException("Could not extract method body for " + methodSignature); + } + + static string ExtractNativeFunctionBody(string mm, string functionName) + { + var marker = "void " + functionName; + int start = mm.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + { + marker = "char *" + functionName; + start = mm.IndexOf(marker, StringComparison.Ordinal); + } + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Native export missing: " + functionName); + int brace = mm.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < mm.Length; i++) + { + if (mm[i] == '{') depth++; + else if (mm[i] == '}') + { + depth--; + if (depth == 0) + return mm.Substring(start, i - start + 1); + } + } + + throw new InvalidOperationException("Could not extract native body for " + functionName); + } + + [Test] + public void Facade_exposes_public_api_methods() + { + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + Assert.That(facade, Does.Contain("DeleteCollectedDataOnDevice")); + Assert.That(facade, Does.Contain("CreateReport")); + Assert.That(facade, Does.Contain("AddBreadcrumb")); + Assert.That(facade, Does.Contain("AddNetworkEvent")); + } + + [Test] + public void Unknown_breadcrumb_level_throws_with_breadcrumb_level_message() + { + var ex = Assert.Throws(() => BreadcrumbLevelMap.ParseOrThrow("verbose")); + Assert.That(ex.Message, Does.Contain("breadcrumb level")); + } + + [Test] + public void Ios_delete_collected_data_body_uses_main_thread_hop() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); + Assert.That(body, Does.Contain("BugseeRunOnMain")); + Assert.That(body, Does.Contain("deleteCollectedDataOnDevice")); + } + + [Test] + public void Ios_channel_exports_use_gChannel_in_callbacks() + { + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + string network = ExtractNativeFunctionBody(callbacks, "_bugsee_channel_network"); + Assert.That(network, Does.Contain("gChannel")); + Assert.That(network, Does.Contain("addNetworkEvent:requiresFiltering:")); + + string breadcrumb = ExtractNativeFunctionBody(callbacks, "_bugsee_channel_breadcrumb"); + Assert.That(breadcrumb, Does.Contain("gChannel")); + Assert.That(breadcrumb, Does.Contain("addBreadcrumb:")); + + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + Assert.That(bridge, Does.Not.Contain("BugseeInstallChannelCapture")); + Assert.That(bridge, Does.Not.Contain("method_exchangeImplementations")); + Assert.That(bridge, Does.Not.Contain("_bugsee_channel_network")); + Assert.That(bridge, Does.Not.Contain("_bugsee_channel_breadcrumb")); + } + + [Test] + public void Ios_apply_open_report_clears_attachments_before_re_add() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); + Assert.That(body, Does.Contain("clearAttachments")); + Assert.That(body, Does.Contain("addAttachmentWithData:name:mimeType:")); + Assert.That(body, Does.Not.Contain("createAndAddAttachmentWithName")); + } + + [Test] + public void Editor_CreateReport_throws_NotSupportedException() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Editor/EditorBridge.cs")), + "public IReport CreateReport()"); + Assert.That(body, Does.Contain("NotSupportedException")); + } + + [Test] + public void Editor_AddBreadcrumb_unknown_level_throws_before_no_op() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Editor/EditorBridge.cs")), + "public void AddBreadcrumb(string category, string message, string levelName)"); + Assert.That(body, Does.Contain("BreadcrumbLevelMap.ParseOrThrow")); + Assert.That(body, Does.Not.Contain("_bugsee_")); + Assert.That(body, Does.Not.Contain("CallStatic")); + } + + [Test] + public void Editor_DeleteCollectedDataOnDevice_is_no_op() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Editor/EditorBridge.cs")), + "public void DeleteCollectedDataOnDevice()"); + Assert.That(body, Does.Not.Contain("_bugsee_")); + Assert.That(body, Does.Not.Contain("CallStatic")); + } + + [Test] + public void Android_does_not_use_network_launch_buffer() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(android, Does.Not.Contain("NetworkEventLaunchBuffer")); + Assert.That(android, Does.Not.Contain("_networkLaunchBuffer")); + } + + [Test] + public void Android_delete_collected_data_skips_when_launched() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")), + "public void DeleteCollectedDataOnDevice()"); + Assert.That(body, Does.Match(new Regex(@"if\s*\(\s*GetLaunched\s*\(\s*\)\s*\)\s*return"))); + Assert.That(body, Does.Contain("deleteCollectedDataOnDevice")); + } + + [Test] + public void Android_breadcrumb_bridge_passes_ToAndroid_mapping() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")), + "public void AddBreadcrumb(string category, string message, string levelName)"); + Assert.That(body, Does.Contain("BreadcrumbLevelMap.ParseOrThrow")); + Assert.That(body, Does.Contain("BreadcrumbLevelMap.ToAndroid(level)")); + Assert.That(BreadcrumbLevelMap.TryParse("debug", out var debugLevel), Is.True); + Assert.That(BreadcrumbLevelMap.ToAndroid(debugLevel), Is.EqualTo(1)); + Assert.That(BreadcrumbLevelMap.TryParse("fatal", out var fatalLevel), Is.True); + Assert.That(BreadcrumbLevelMap.ToAndroid(fatalLevel), Is.EqualTo(5)); + } + + [Test] + public void Ios_network_buffer_wiring() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + + string addNetwork = ExtractMethodBody(ios, "public void AddNetworkEvent(INetworkEvent networkEvent)"); + Assert.That(addNetwork, Does.Contain("_networkLaunchBuffer.Enqueue")); + + string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); + Assert.That(notify, Does.Contain("NetworkLaunchPhase.Launched")); + Assert.That(notify, Does.Contain("NetworkLaunchPhase.Stopped")); + + string launch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); + Assert.That(launch, Does.Contain("SetPhase(NetworkLaunchPhase.BeforeLaunched)")); + + string stop = ExtractMethodBody(ios, "public void Stop(Action completion = null)"); + Assert.That(stop, Does.Contain("_bugsee_clear_wrapper_channel")); + Assert.That(stop, Does.Not.Contain("_bugsee_clear_bridge_wrapper_channel")); + Assert.That(stop, Does.Contain("SetPhase(NetworkLaunchPhase.Stopped)")); + + string facadeLifecycle = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Bugsee.cs")), + "internal static void HandleNativeLifecycle(string eventType, object data)"); + Assert.That(facadeLifecycle, Does.Contain("Bridge.NotifyLifecycle")); + } + + [Test] + public void Ios_breadcrumb_bridge_passes_ToIos_mapping() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public void AddBreadcrumb(string category, string message, string levelName)"); + Assert.That(body, Does.Contain("BreadcrumbLevelMap.ParseOrThrow")); + Assert.That(body, Does.Contain("BreadcrumbLevelMap.ToIos(level)")); + Assert.That(BreadcrumbLevelMap.TryParse("debug", out var debugLevel), Is.True); + Assert.That(BreadcrumbLevelMap.ToIos(debugLevel), Is.EqualTo(4)); + Assert.That(BreadcrumbLevelMap.TryParse("fatal", out var fatalLevel), Is.True); + Assert.That(BreadcrumbLevelMap.ToIos(fatalLevel), Is.EqualTo(1)); + } + + [Test] + public void Ios_create_report_guards_second_open_report() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public IReport CreateReport()"); + Assert.That(body, Does.Contain("a report is already open")); + } + + [Test] + public void Ios_submit_network_uses_ToNativeMapJson_for_headers() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "void SubmitNetworkEventToChannel(INetworkEvent networkEvent)"); + Assert.That(body, Does.Contain("ToNativeMapJson")); + Assert.That(body, Does.Contain("\"headers\"")); + } + + [Test] + public void Ios_live_report_wraps_attachment_list() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + + string attachments = ExtractMethodBody(ios, "public IReadOnlyList Attachments"); + Assert.That(attachments, Does.Contain("IosLiveAttachment(iosAttachment, _owner)")); + + string create = ExtractMethodBody(ios, "public IAttachment CreateAndAddAttachment(string name)"); + Assert.That(create, Does.Contain("(IosAttachment)")); + } + } +} From eb46e27430a3baf77bb72072b2f5e9181a6aa7b1 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:43:40 +0500 Subject: [PATCH 046/157] Harden iOS attachment snapshots and preserve file metadata. Co-authored-by: Cursor --- Runtime/Platform/IOS/IosNativeCallbacks.cs | 7 +- Runtime/Platform/IOS/IosReport.cs | 67 +++++++++++++++++-- Tests~/WrapperPolicy/ReportPathSourceTests.cs | 3 + 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index c7ceec1..0da867a 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -230,12 +230,13 @@ static void OnReport(long requestId, int phase, int isTerminating, string json) void Work() { + IosReport report = null; try { var handler = _reportHandler; var dto = JsonUtility.FromJson( IosJsonNormalize.NormalizeReport(json ?? "{}")); - var report = new IosReport(dto); + report = new IosReport(dto); if (handler == null) { @@ -261,6 +262,10 @@ void Work() Debug.LogException(ex); _bugsee_complete_report(requestId, null); } + finally + { + report?.ReleaseSnapshotFiles(); + } } if (MainThreadDispatcher.IsMainThread) diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index 1a2e0d8..1b6b1bf 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -142,11 +142,25 @@ public IAttachment AddAttachmentFile(string path, string name, string mimeType) if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; - var snapshotPath = SnapshotAttachmentFile(path); + string snapshotPath; + try + { + snapshotPath = SnapshotAttachmentFile(path); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + var att = new IosAttachment(name ?? "attachment"); - att.Filename = name ?? "attachment"; + var baseName = Path.GetFileName(path); + att.Filename = string.IsNullOrEmpty(baseName) ? (name ?? "attachment") : baseName; att.MimeType = mimeType ?? "application/octet-stream"; - att.SetFilePath(snapshotPath); + att.SetSnapshotPath(snapshotPath); _attachments.Add(att); return att; } @@ -175,7 +189,18 @@ static string SnapshotAttachmentFile(string sourcePath) return dest; } - public void ClearAttachments() => _attachments.Clear(); + public void ClearAttachments() + { + for (var i = 0; i < _attachments.Count; i++) + _attachments[i].DeleteSnapshotIfOwned(); + _attachments.Clear(); + } + + public void ReleaseSnapshotFiles() + { + for (var i = 0; i < _attachments.Count; i++) + _attachments[i].DeleteSnapshotIfOwned(); + } public string ToResultJson() { @@ -262,6 +287,7 @@ sealed class IosAttachment : IAttachment string _text; string _dataBase64; bool _isBinary; + bool _ownsSnapshotFile; public IosAttachment(string name) { @@ -316,6 +342,36 @@ public void SetFilePath(string path) _text = null; _dataBase64 = null; _isBinary = false; + _ownsSnapshotFile = false; + } + + public void SetSnapshotPath(string path) + { + _path = path; + _text = null; + _dataBase64 = null; + _isBinary = false; + _ownsSnapshotFile = true; + } + + public void DeleteSnapshotIfOwned() + { + if (!_ownsSnapshotFile || string.IsNullOrEmpty(_path)) + return; + + try + { + if (File.Exists(_path)) + File.Delete(_path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + _ownsSnapshotFile = false; } public void SetAttachmentBytes(byte[] data) @@ -324,8 +380,7 @@ public void SetAttachmentBytes(byte[] data) _text = null; _isBinary = true; _dataBase64 = Convert.ToBase64String(data); - if (string.IsNullOrEmpty(MimeType) || MimeType == "text/plain") - MimeType = "application/octet-stream"; + _ownsSnapshotFile = false; } public string ToJsonObject() diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index 4fe3179..2d9a60d 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -194,6 +194,9 @@ public void Ios_report_snapshots_files_and_base64_bytes() string addFile = ExtractMethodBody(report, "public IAttachment AddAttachmentFile(string path, string name, string mimeType)"); Assert.That(addFile, Does.Contain("SnapshotAttachmentFile")); Assert.That(addFile, Does.Contain("File.Exists(path)")); + Assert.That(addFile, Does.Contain("Path.GetFileName(path)")); + Assert.That(addFile, Does.Contain("SetSnapshotPath")); + Assert.That(addFile, Does.Contain("catch (IOException)")); string addBytes = ExtractMethodBody(report, "public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType)"); Assert.That(addBytes, Does.Contain("SetAttachmentBytes")); From a4b1029a67551d3a482b423a5c970d492ce4c87e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:50:09 +0500 Subject: [PATCH 047/157] Release iOS attachment snapshots only after native complete. Co-authored-by: Cursor --- Runtime/Platform/IOS/IosNativeCallbacks.cs | 16 +++++++++------- Tests~/WrapperPolicy/ReportPathSourceTests.cs | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 0da867a..8af3340 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -240,7 +240,7 @@ void Work() if (handler == null) { - _bugsee_complete_report(requestId, null); + CompleteReport(requestId, report, null); return; } @@ -249,7 +249,7 @@ void Work() { if (finished) return; finished = true; - _bugsee_complete_report(requestId, report.ToResultJson()); + CompleteReport(requestId, report, report.ToResultJson()); }; if (phase == 0) @@ -260,14 +260,16 @@ void Work() catch (Exception ex) { Debug.LogException(ex); - _bugsee_complete_report(requestId, null); - } - finally - { - report?.ReleaseSnapshotFiles(); + CompleteReport(requestId, report, null); } } + static void CompleteReport(long requestId, IosReport report, string resultJson) + { + _bugsee_complete_report(requestId, resultJson); + report?.ReleaseSnapshotFiles(); + } + if (MainThreadDispatcher.IsMainThread) Work(); else diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index 2d9a60d..9dfcb18 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -75,6 +75,21 @@ public void Ios_forwardReport_terminates_before_report_callback() Assert.That(terminatingBranch, Does.Not.Contain("gReportCb")); } + [Test] + public void Ios_on_report_releases_snapshots_after_complete() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosNativeCallbacks.cs")); + string onReport = ExtractMethodBody(ios, "static void OnReport(long requestId, int phase, int isTerminating, string json)"); + Assert.That(onReport, Does.Contain("CompleteReport(requestId, report")); + Assert.That(onReport, Does.Contain("ReleaseSnapshotFiles")); + Assert.That(onReport, Does.Not.Contain("finally")); + string complete = ExtractMethodBody(onReport, "static void CompleteReport(long requestId, IosReport report, string resultJson)"); + int completeReport = complete.IndexOf("_bugsee_complete_report", StringComparison.Ordinal); + int release = complete.IndexOf("ReleaseSnapshotFiles", StringComparison.Ordinal); + Assert.That(completeReport, Is.GreaterThanOrEqualTo(0)); + Assert.That(release, Is.GreaterThan(completeReport)); + } + [Test] public void Ios_on_report_skips_csharp_when_terminating() { From a74b63fef13886782b351fc2efe3d0bb21c0485e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:57:43 +0500 Subject: [PATCH 048/157] Match beta5 report and breadcrumb selectors and release a cancelled report. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 124 +++++--------- Plugins/iOS/BugseeUnityCallbacks.mm | 12 +- Runtime/Bugsee.cs | 3 + Runtime/Platform/Android/AndroidBridge.cs | 10 +- Runtime/Platform/Editor/EditorBridge.cs | 6 + Runtime/Platform/IBugseeNativeBridge.cs | 1 + Runtime/Platform/IOS/IOSBridge.cs | 154 +++++++----------- .../WrapperPolicy/NetworkEventLaunchBuffer.cs | 67 +++++--- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 47 +++++- 9 files changed, 209 insertions(+), 215 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 0e0bbd7..73f5bba 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -44,7 +44,7 @@ static void BugseeRunOnMain(dispatch_block_t block) } } -static id gOpenReport; +typedef void (*BugseeManagedReportCreateCallback)(int succeeded); static void BugseeBridgeApplyReportDict(id report, NSDictionary *d) { @@ -91,13 +91,7 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona id attachments = d[@"attachments"]; if ([attachments isKindOfClass:[NSArray class]]) { - id reportObj = (id)report; - if ([reportObj respondsToSelector:@selector(clearAttachments)]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [reportObj performSelector:@selector(clearAttachments)]; -#pragma clang diagnostic pop - } + [report clearAttachments]; for (id item in (NSArray *)attachments) { if (![item isKindOfClass:[NSDictionary class]]) { continue; @@ -106,9 +100,8 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona NSString *name = [att[@"name"] isKindOfClass:[NSString class]] ? att[@"name"] : @"attachment"; NSString *mime = [att[@"mimeType"] isKindOfClass:[NSString class]] ? att[@"mimeType"] : @"text/plain"; id filePath = att[@"filePath"]; - if ([filePath isKindOfClass:[NSString class]] && [(NSString *)filePath length] > 0 - && [reportObj respondsToSelector:@selector(addAttachmentWithFilePath:name:mimeType:move:)]) { - [reportObj addAttachmentWithFilePath:filePath name:name mimeType:mime move:NO]; + if ([filePath isKindOfClass:[NSString class]] && [(NSString *)filePath length] > 0) { + [report addAttachmentWithFilePath:filePath name:name mimeType:mime move:NO]; continue; } NSData *data = nil; @@ -123,45 +116,9 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona if (!data) { data = [NSData data]; } - if ([reportObj respondsToSelector:@selector(addAttachmentWithData:name:mimeType:)]) { - [reportObj addAttachmentWithData:data name:name mimeType:mime]; - } - } - } -} - -static NSDictionary *BugseeBridgeReportToDict(id report) -{ - NSMutableDictionary *d = [NSMutableDictionary dictionary]; - d[@"id"] = report.reportId ?: @""; - d[@"summary"] = report.summary ?: @""; - d[@"description"] = report.reportDescription ?: @""; - d[@"email"] = report.email ?: @""; - d[@"severity"] = @(report.severity); - d[@"labels"] = report.labels ? [report.labels copy] : @[]; - d[@"attributes"] = report.attributes ? [report.attributes copy] : @{}; - if ([(id)report respondsToSelector:@selector(type)]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - id typeVal = [(id)report performSelector:@selector(type)]; -#pragma clang diagnostic pop - if ([typeVal isKindOfClass:[NSString class]]) { - d[@"type"] = typeVal; + [report addAttachmentWithData:data name:name mimeType:mime]; } } - return d; -} - -static NSString *BugseeJsonStringFromObject(id obj) -{ - if (!obj || ![NSJSONSerialization isValidJSONObject:obj]) { - return @"{}"; - } - NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:0 error:nil]; - if (!data) { - return @"{}"; - } - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}"; } extern "C" { @@ -480,53 +437,43 @@ void _bugsee_free(char *ptr) void _bugsee_delete_collected_data(void) { BugseeRunOnMain(^{ - [Bugsee deleteCollectedDataOnDevice]; + [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; }); } -char *_bugsee_create_report(void) -{ - if (gOpenReport) { - return NULL; - } - id report = [Bugsee createReport]; - if (!report) { - return NULL; - } - gOpenReport = report; - return BugseeCopyUTF8(BugseeJsonStringFromObject(BugseeBridgeReportToDict(report))); -} - -void _bugsee_apply_open_report(const char *reportJson) -{ - if (!gOpenReport || !reportJson) { - return; - } - NSDictionary *dict = BugseeDeserializeJson(reportJson); - if (![dict isKindOfClass:[NSDictionary class]]) { - return; - } - BugseeBridgeApplyReportDict(gOpenReport, dict); -} - -void _bugsee_upload_open_report(void) +void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) { - if (!gOpenReport) { + if (!reportJson) { + if (callback) { + callback(0); + } return; } - [Bugsee uploadReport:gOpenReport completion:nil]; - gOpenReport = nil; -} - -void _bugsee_release_open_report(void) -{ - gOpenReport = nil; + NSString *jsonCopy = [NSString stringWithUTF8String:reportJson]; + [Bugsee createReportWithCompletion:^(BugseeExtendedReport *_Nullable report) { + if (!report) { + if (callback) { + callback(0); + } + return; + } + NSDictionary *dict = BugseeDeserializeJson(jsonCopy.UTF8String); + if ([dict isKindOfClass:[NSDictionary class]]) { + BugseeBridgeApplyReportDict((id)report, dict); + } + if (callback) { + callback(1); + } + [Bugsee uploadReport:report completion:nil]; + }]; } } // extern "C" #else // !BUGSEE_IOS_SDK +typedef void (*BugseeManagedReportCreateCallback)(int succeeded); + extern "C" { void _bugsee_launch(const char *appToken, const char *optionsJson) { (void)appToken; (void)optionsJson; } @@ -562,10 +509,13 @@ void _bugsee_feedback_show(void) {} char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } void _bugsee_delete_collected_data(void) {} -char *_bugsee_create_report(void) { return NULL; } -void _bugsee_apply_open_report(const char *reportJson) { (void)reportJson; } -void _bugsee_upload_open_report(void) {} -void _bugsee_release_open_report(void) {} +void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) +{ + (void)reportJson; + if (callback) { + callback(0); + } +} } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 99496ca..dc0351b 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -460,11 +460,19 @@ void _bugsee_channel_breadcrumb(const char *category, const char *message, int i if (!messageText) { messageText = @""; } - id breadcrumb = [BugseeBreadcrumb breadcrumbWithCategory:categoryText message:messageText]; + id factory = [Bugsee getExchangeFactory]; + if (!factory) { + return; + } + id breadcrumb = [factory createBreadcrumbWithTimestamp:[[NSDate date] timeIntervalSince1970] + category:categoryText + level:(BugseeLogLevel)iosLevel + message:messageText + type:@"manual" + data:nil]; if (!breadcrumb) { return; } - breadcrumb.level = (BugseeLogLevel)iosLevel; [channel addBreadcrumb:breadcrumb]; } diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 8f98a3f..85a380d 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -90,6 +90,9 @@ public static void DeleteCollectedDataOnDevice() => public static IReport CreateReport() => Bridge.CreateReport(); + /// Release a report from without uploading. + public static void Discard(IReport report) => Bridge.DiscardReport(report); + public static void AddBreadcrumb(string category, string message, string levelName) => Bridge.AddBreadcrumb(category, message, levelName); diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 874a172..1ddecc6 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -443,16 +443,24 @@ public void NotifyLifecycle(string eventType) { } public void DeleteCollectedDataOnDevice() { if (GetLaunched()) - return; + Stop(); _bugsee.CallStatic("deleteCollectedDataOnDevice"); } public IReport CreateReport() { var javaReport = _bugsee.CallStatic("createReport"); + if (javaReport == null) + throw new InvalidOperationException("CreateReport failed."); return new AndroidReport(javaReport); } + public void DiscardReport(IReport report) + { + if (report == null) + throw new ArgumentNullException(nameof(report)); + } + public void UploadReport(IReport report) { if (report == null) diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index 09ed831..12eb5f3 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -78,6 +78,12 @@ public void AddBreadcrumb(string category, string message, string levelName) public void UploadReport(IReport report) => throw new NotSupportedException("Upload(IReport) is not supported in the Unity Editor."); + public void DiscardReport(IReport report) + { + if (report == null) + throw new ArgumentNullException(nameof(report)); + } + public void EnsureWrapperRegistered() { } sealed class StubAppearance : IAppearance diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index e9b22ef..59fc261 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -72,6 +72,7 @@ public interface IBugseeNativeBridge void DeleteCollectedDataOnDevice(); IReport CreateReport(); + void DiscardReport(IReport report); void AddBreadcrumb(string category, string message, string levelName); void UploadReport(IReport report); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index e4a154c..bf6ea56 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using AOT; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Feedback; @@ -23,6 +24,10 @@ namespace Bugsee.Platform.IOS /// sealed class IOSBridge : IBugseeNativeBridge { + delegate void ManagedReportUploadNativeCallback(int succeeded); + + static IOSBridge _pendingManagedReportUpload; + bool _launched; bool _blackout; IosAppearance _appearance; @@ -81,11 +86,7 @@ public void Stop(Action completion = null) _bugsee_clear_wrapper_channel(); _bugsee_stop(); _launched = false; - if (_openReport != null) - { - ApplyOpenReportToNative(); - _bugsee_release_open_report(); - } + _pendingManagedReportUpload = null; _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); @@ -134,46 +135,61 @@ public void NotifyLifecycle(string eventType) _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); } - public void DeleteCollectedDataOnDevice() => _bugsee_delete_collected_data(); + public void DeleteCollectedDataOnDevice() + { + if (GetLaunched()) + Stop(); + _bugsee_delete_collected_data(); + } public IReport CreateReport() { if (_openReport != null) throw new InvalidOperationException("a report is already open"); - var json = ConsumeNativeString(_bugsee_create_report()); - if (string.IsNullOrEmpty(json)) - throw new InvalidOperationException("CreateReport failed."); - var dto = JsonUtility.FromJson(IosJsonNormalize.NormalizeReport(json)); - _openReport = new IosReport(dto); - _openReportHandle = new IosLiveReport(_openReport, this); + _openReport = new IosReport(new IosReportDto()); + _openReportHandle = new IosLiveReport(_openReport); return _openReportHandle; } - public void UploadReport(IReport report) + public void DiscardReport(IReport report) { + if (report == null) + throw new ArgumentNullException(nameof(report)); if (!ReferenceEquals(report, _openReportHandle)) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); - ApplyOpenReportToNative(); - _bugsee_upload_open_report(); _openReport = null; _openReportHandle = null; } - internal void ApplyOpenReportToNative() + public void UploadReport(IReport report) { + if (!ReferenceEquals(report, _openReportHandle)) + throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); if (_openReport == null) - return; - _bugsee_apply_open_report(_openReport.ToResultJson()); + throw new InvalidOperationException("CreateReport failed."); + if (_pendingManagedReportUpload != null) + throw new InvalidOperationException("a report upload is already in progress"); + var json = _openReport.ToResultJson(); + _pendingManagedReportUpload = this; + _bugsee_upload_managed_report(json, OnManagedReportCreateCompletion); } - internal void ReleaseOpenReportFromLiveHandle(IosLiveReport handle) + [MonoPInvokeCallback(typeof(ManagedReportUploadNativeCallback))] + static void OnManagedReportCreateCompletion(int succeeded) { - if (!ReferenceEquals(handle, _openReportHandle)) + var bridge = _pendingManagedReportUpload; + _pendingManagedReportUpload = null; + if (bridge == null) return; - ApplyOpenReportToNative(); - _bugsee_release_open_report(); + bridge.FinishManagedReportUpload(succeeded != 0); + } + + void FinishManagedReportUpload(bool succeeded) + { _openReport = null; _openReportHandle = null; + if (!succeeded) + Debug.LogError("[Bugsee] CreateReport failed."); } public void AddBreadcrumb(string category, string message, string levelName) @@ -413,10 +429,9 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); - [DllImport("__Internal")] static extern IntPtr _bugsee_create_report(); - [DllImport("__Internal")] static extern void _bugsee_apply_open_report(string reportJson); - [DllImport("__Internal")] static extern void _bugsee_upload_open_report(); - [DllImport("__Internal")] static extern void _bugsee_release_open_report(); + [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( + string reportJson, + ManagedReportUploadNativeCallback callback); [DllImport("__Internal")] static extern void _bugsee_trace(string name, string valueJson); [DllImport("__Internal")] static extern void _bugsee_event(string name, string paramsJson); [DllImport("__Internal")] static extern void _bugsee_logException(string name, string reason, bool handled); @@ -495,88 +510,60 @@ public IReadOnlyDictionary ToMap() => new Dictionary(_cache); } - /// Live handle over the native open report created by . - internal sealed class IosLiveReport : IReport, IDisposable + /// Managed report snapshot from ; native work runs at upload. + internal sealed class IosLiveReport : IReport { readonly IosReport _inner; - readonly IOSBridge _owner; - internal IosLiveReport(IosReport inner, IOSBridge owner) + internal IosLiveReport(IosReport inner) { _inner = inner; - _owner = owner; } - public void Dispose() => _owner.ReleaseOpenReportFromLiveHandle(this); - public string Id => _inner.Id; public IssueType Type => _inner.Type; public string Summary { get => _inner.Summary; - set { _inner.Summary = value; _owner.ApplyOpenReportToNative(); } + set => _inner.Summary = value; } public string Description { get => _inner.Description; - set { _inner.Description = value; _owner.ApplyOpenReportToNative(); } + set => _inner.Description = value; } public string Email { get => _inner.Email; - set { _inner.Email = value; _owner.ApplyOpenReportToNative(); } + set => _inner.Email = value; } public IssueSeverity? Severity { get => _inner.Severity; - set { _inner.Severity = value; _owner.ApplyOpenReportToNative(); } + set => _inner.Severity = value; } public IReadOnlyDictionary Attributes => _inner.Attributes; public object GetAttribute(string name) => _inner.GetAttribute(name); - public void SetAttribute(string name, object value) - { - _inner.SetAttribute(name, value); - _owner.ApplyOpenReportToNative(); - } + public void SetAttribute(string name, object value) => _inner.SetAttribute(name, value); - public void RemoveAttribute(string name) - { - _inner.RemoveAttribute(name); - _owner.ApplyOpenReportToNative(); - } + public void RemoveAttribute(string name) => _inner.RemoveAttribute(name); - public void ClearAllAttributes() - { - _inner.ClearAllAttributes(); - _owner.ApplyOpenReportToNative(); - } + public void ClearAllAttributes() => _inner.ClearAllAttributes(); public IReadOnlyList Labels => _inner.Labels; - public void AddLabel(string label) - { - _inner.AddLabel(label); - _owner.ApplyOpenReportToNative(); - } + public void AddLabel(string label) => _inner.AddLabel(label); - public void ClearLabels() - { - _inner.ClearLabels(); - _owner.ApplyOpenReportToNative(); - } + public void ClearLabels() => _inner.ClearLabels(); - public void SetLabels(IEnumerable labels) - { - _inner.SetLabels(labels); - _owner.ApplyOpenReportToNative(); - } + public void SetLabels(IEnumerable labels) => _inner.SetLabels(labels); public IReadOnlyList Attachments { @@ -587,7 +574,7 @@ public IReadOnlyList Attachments for (var i = 0; i < inner.Count; i++) { if (inner[i] is IosAttachment iosAttachment) - wrapped.Add(new IosLiveAttachment(iosAttachment, _owner)); + wrapped.Add(new IosLiveAttachment(iosAttachment)); else wrapped.Add(inner[i]); } @@ -598,57 +585,42 @@ public IReadOnlyList Attachments public IAttachment CreateAndAddAttachment(string name) { var att = (IosAttachment)_inner.CreateAndAddAttachment(name); - _owner.ApplyOpenReportToNative(); - return new IosLiveAttachment(att, _owner); + return new IosLiveAttachment(att); } - public void ClearAttachments() - { - _inner.ClearAttachments(); - _owner.ApplyOpenReportToNative(); - } + public void ClearAttachments() => _inner.ClearAttachments(); } sealed class IosLiveAttachment : IAttachment { readonly IosAttachment _inner; - readonly IOSBridge _owner; - internal IosLiveAttachment(IosAttachment inner, IOSBridge owner) + internal IosLiveAttachment(IosAttachment inner) { _inner = inner; - _owner = owner; } public string Name { get => _inner.Name; - set { _inner.Name = value; _owner.ApplyOpenReportToNative(); } + set => _inner.Name = value; } public string Filename { get => _inner.Filename; - set { _inner.Filename = value; _owner.ApplyOpenReportToNative(); } + set => _inner.Filename = value; } public string MimeType { get => _inner.MimeType; - set { _inner.MimeType = value; _owner.ApplyOpenReportToNative(); } + set => _inner.MimeType = value; } - public void SetData(byte[] data) - { - _inner.SetData(data); - _owner.ApplyOpenReportToNative(); - } + public void SetData(byte[] data) => _inner.SetData(data); - public void SetData(string text) - { - _inner.SetData(text); - _owner.ApplyOpenReportToNative(); - } + public void SetData(string text) => _inner.SetData(text); } } diff --git a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs index d7cc117..6b1fdf6 100644 --- a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs +++ b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs @@ -15,34 +15,45 @@ public enum NetworkLaunchPhase /// public sealed class NetworkEventLaunchBuffer { + readonly object _gate = new object(); readonly List _pending = new List(); NetworkLaunchPhase _phase = NetworkLaunchPhase.BeforeLaunched; Action _submit; - public NetworkLaunchPhase Phase => _phase; + public NetworkLaunchPhase Phase + { + get + { + lock (_gate) + return _phase; + } + } public void SetSubmitHandler(Action submit) => _submit = submit; public void SetPhase(NetworkLaunchPhase phase) { - if (phase == NetworkLaunchPhase.Launched) + lock (_gate) { - if (_phase == NetworkLaunchPhase.Launched) + if (phase == NetworkLaunchPhase.Launched) + { + if (_phase == NetworkLaunchPhase.Launched) + return; + _phase = NetworkLaunchPhase.Launched; + FlushPendingLocked(); return; - _phase = NetworkLaunchPhase.Launched; - FlushPending(); - return; - } + } - if (phase == NetworkLaunchPhase.Stopped) - { - _phase = NetworkLaunchPhase.Stopped; - _pending.Clear(); - return; - } + if (phase == NetworkLaunchPhase.Stopped) + { + _phase = NetworkLaunchPhase.Stopped; + _pending.Clear(); + return; + } - if (phase == NetworkLaunchPhase.BeforeLaunched) - _phase = NetworkLaunchPhase.BeforeLaunched; + if (phase == NetworkLaunchPhase.BeforeLaunched) + _phase = NetworkLaunchPhase.BeforeLaunched; + } } public void Enqueue(T item) @@ -50,19 +61,24 @@ public void Enqueue(T item) if (item == null) return; - if (_phase == NetworkLaunchPhase.Stopped) - return; - - if (_phase == NetworkLaunchPhase.Launched) + lock (_gate) { - _submit?.Invoke(item); - return; - } + if (_phase == NetworkLaunchPhase.Stopped) + return; - _pending.Add(item); + if (_phase == NetworkLaunchPhase.Launched) + { + var submit = _submit; + if (submit != null) + submit(item); + return; + } + + _pending.Add(item); + } } - void FlushPending() + void FlushPendingLocked() { if (_submit == null || _pending.Count == 0) { @@ -72,8 +88,9 @@ void FlushPending() var copy = new List(_pending); _pending.Clear(); + var submit = _submit; for (var i = 0; i < copy.Count; i++) - _submit(copy[i]); + submit(copy[i]); } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index e363d2e..ee48fdb 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -77,6 +77,7 @@ public void Facade_exposes_public_api_methods() Assert.That(facade, Does.Contain("CreateReport")); Assert.That(facade, Does.Contain("AddBreadcrumb")); Assert.That(facade, Does.Contain("AddNetworkEvent")); + Assert.That(facade, Does.Contain("Discard")); } [Test] @@ -92,7 +93,7 @@ public void Ios_delete_collected_data_body_uses_main_thread_hop() string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); Assert.That(body, Does.Contain("BugseeRunOnMain")); - Assert.That(body, Does.Contain("deleteCollectedDataOnDevice")); + Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); } [Test] @@ -115,13 +116,29 @@ public void Ios_channel_exports_use_gChannel_in_callbacks() } [Test] - public void Ios_apply_open_report_clears_attachments_before_re_add() + public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() { string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); - string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); - Assert.That(body, Does.Contain("clearAttachments")); - Assert.That(body, Does.Contain("addAttachmentWithData:name:mimeType:")); - Assert.That(body, Does.Not.Contain("createAndAddAttachmentWithName")); + string upload = ExtractNativeFunctionBody(bridge, "_bugsee_upload_managed_report"); + Assert.That(upload, Does.Contain("createReportWithCompletion:")); + Assert.That(upload, Does.Contain("uploadReport:")); + Assert.That(upload, Does.Contain("BugseeExtendedReport")); + + string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); + Assert.That(apply, Does.Contain("[report clearAttachments]")); + Assert.That(apply, Does.Contain("addAttachmentWithData:")); + Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); + Assert.That(bridge, Does.Not.Contain("_bugsee_apply_open_report")); + Assert.That(bridge, Does.Not.Contain("[Bugsee createReport]")); + } + + [Test] + public void Ios_channel_breadcrumb_uses_exchange_factory() + { + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + string body = ExtractNativeFunctionBody(callbacks, "_bugsee_channel_breadcrumb"); + Assert.That(body, Does.Contain("createBreadcrumbWithTimestamp:")); + Assert.That(body, Does.Not.Contain("BugseeBreadcrumb")); } [Test] @@ -163,12 +180,12 @@ public void Android_does_not_use_network_launch_buffer() } [Test] - public void Android_delete_collected_data_skips_when_launched() + public void Android_delete_collected_data_stops_then_deletes() { string body = ExtractMethodBody( File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")), "public void DeleteCollectedDataOnDevice()"); - Assert.That(body, Does.Match(new Regex(@"if\s*\(\s*GetLaunched\s*\(\s*\)\s*\)\s*return"))); + Assert.That(body, Does.Match(new Regex(@"if\s*\(\s*GetLaunched\s*\(\s*\)\s*\)\s*Stop\s*\(\s*\)\s*;"))); Assert.That(body, Does.Contain("deleteCollectedDataOnDevice")); } @@ -233,6 +250,17 @@ public void Ios_create_report_guards_second_open_report() File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), "public IReport CreateReport()"); Assert.That(body, Does.Contain("a report is already open")); + Assert.That(body, Does.Not.Contain("_bugsee_create_report")); + } + + [Test] + public void Ios_discard_report_clears_managed_slot_without_native() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public void DiscardReport(IReport report)"); + Assert.That(body, Does.Not.Contain("_bugsee_")); + Assert.That(body, Does.Contain("_openReport = null")); } [Test] @@ -251,7 +279,8 @@ public void Ios_live_report_wraps_attachment_list() string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); string attachments = ExtractMethodBody(ios, "public IReadOnlyList Attachments"); - Assert.That(attachments, Does.Contain("IosLiveAttachment(iosAttachment, _owner)")); + Assert.That(attachments, Does.Contain("IosLiveAttachment(iosAttachment)")); + Assert.That(ios, Does.Not.Contain("ApplyOpenReportToNative")); string create = ExtractMethodBody(ios, "public IAttachment CreateAndAddAttachment(string name)"); Assert.That(create, Does.Contain("(IosAttachment)")); From 0dc19cf97f1d73aba8a972d518fcca90f315b8f5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:10:07 +0500 Subject: [PATCH 049/157] Match beta5 report and breadcrumb selectors and release a cancelled report. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 10 ++++++++ Runtime/Platform/Android/AndroidBridge.cs | 13 ++++++++-- Runtime/Platform/IOS/IOSBridge.cs | 25 +++++++++++++++++--- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 24 +++++++++++++++---- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 73f5bba..d2a912d 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -441,6 +441,15 @@ void _bugsee_delete_collected_data(void) }); } +void _bugsee_stop_then_delete_collected_data(void) +{ + BugseeRunOnMain(^{ + [Bugsee stop:^{ + [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + }]; + }); +} + void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) { if (!reportJson) { @@ -509,6 +518,7 @@ void _bugsee_feedback_show(void) {} char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } void _bugsee_delete_collected_data(void) {} +void _bugsee_stop_then_delete_collected_data(void) {} void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) { (void)reportJson; diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 1ddecc6..f349e1a 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -443,8 +443,17 @@ public void NotifyLifecycle(string eventType) { } public void DeleteCollectedDataOnDevice() { if (GetLaunched()) - Stop(); - _bugsee.CallStatic("deleteCollectedDataOnDevice"); + { + Stop(InvokeDeleteCollectedDataOnDevice); + return; + } + + InvokeDeleteCollectedDataOnDevice(); + } + + void InvokeDeleteCollectedDataOnDevice() + { + _bugsee.CallStatic("deleteCollectedDataOnDevice", true, new RunnableProxy(() => { })); } public IReport CreateReport() diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index bf6ea56..11f7557 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -137,9 +137,27 @@ public void NotifyLifecycle(string eventType) public void DeleteCollectedDataOnDevice() { - if (GetLaunched()) - Stop(); - _bugsee_delete_collected_data(); + if (!GetLaunched()) + { + _bugsee_delete_collected_data(); + return; + } + + if (!MainThreadDispatcher.RunSyncLifecycle(() => + { + HostLogForwarder.Uninstall(); + ExceptionPipeline.Uninstall(); + _bugsee_clear_wrapper_channel(); + _pendingManagedReportUpload = null; + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _launched = false; + _bugsee_stop_then_delete_collected_data(); + })) + { + Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); + } } public IReport CreateReport() @@ -429,6 +447,7 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); + [DllImport("__Internal")] static extern void _bugsee_stop_then_delete_collected_data(); [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( string reportJson, ManagedReportUploadNativeCallback callback); diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index ee48fdb..e3f7445 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -182,11 +182,25 @@ public void Android_does_not_use_network_launch_buffer() [Test] public void Android_delete_collected_data_stops_then_deletes() { - string body = ExtractMethodBody( - File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")), - "public void DeleteCollectedDataOnDevice()"); - Assert.That(body, Does.Match(new Regex(@"if\s*\(\s*GetLaunched\s*\(\s*\)\s*\)\s*Stop\s*\(\s*\)\s*;"))); - Assert.That(body, Does.Contain("deleteCollectedDataOnDevice")); + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + string body = ExtractMethodBody(android, "public void DeleteCollectedDataOnDevice()"); + Assert.That(body, Does.Contain("Stop(InvokeDeleteCollectedDataOnDevice)")); + string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice()"); + Assert.That(invoke, Does.Contain("deleteCollectedDataOnDevice")); + Assert.That(invoke, Does.Contain("true")); + } + + [Test] + public void Ios_delete_when_launched_chains_stop_completion_before_delete() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "_bugsee_stop_then_delete_collected_data"); + Assert.That(body, Does.Contain("[Bugsee stop:")); + Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); + + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string delete = ExtractMethodBody(ios, "public void DeleteCollectedDataOnDevice()"); + Assert.That(delete, Does.Contain("_bugsee_stop_then_delete_collected_data")); } [Test] From b1a6d563cead862d12f2f33ef7e03411b7880315 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:19:55 +0500 Subject: [PATCH 050/157] Use Boolean consumer for Android delete and apply attachment fileName. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 9 ++++++-- Runtime/Platform/Android/AndroidBridge.cs | 9 +++++++- .../Android/Proxies/CallbackProxies.cs | 21 +++++++++++++++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 2 ++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index d2a912d..22aab94 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -98,10 +98,15 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona } NSDictionary *att = (NSDictionary *)item; NSString *name = [att[@"name"] isKindOfClass:[NSString class]] ? att[@"name"] : @"attachment"; + id fileNameVal = att[@"fileName"]; + NSString *fileName = [fileNameVal isKindOfClass:[NSString class]] ? fileNameVal : nil; + if (fileName.length == 0) { + fileName = name; + } NSString *mime = [att[@"mimeType"] isKindOfClass:[NSString class]] ? att[@"mimeType"] : @"text/plain"; id filePath = att[@"filePath"]; if ([filePath isKindOfClass:[NSString class]] && [(NSString *)filePath length] > 0) { - [report addAttachmentWithFilePath:filePath name:name mimeType:mime move:NO]; + [report addAttachmentWithFilePath:filePath name:fileName mimeType:mime move:NO]; continue; } NSData *data = nil; @@ -116,7 +121,7 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona if (!data) { data = [NSData data]; } - [report addAttachmentWithData:data name:name mimeType:mime]; + [report addAttachmentWithData:data name:fileName mimeType:mime]; } } } diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index f349e1a..dfc8caa 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -453,7 +453,14 @@ public void DeleteCollectedDataOnDevice() void InvokeDeleteCollectedDataOnDevice() { - _bugsee.CallStatic("deleteCollectedDataOnDevice", true, new RunnableProxy(() => { })); + _bugsee.CallStatic( + "deleteCollectedDataOnDevice", + true, + new BooleanConsumerProxy(success => + { + if (!success) + Debug.LogError("[Bugsee] deleteCollectedDataOnDevice failed."); + })); } public IReport CreateReport() diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index f729b56..5e7cdf6 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -168,5 +168,26 @@ public RunnableProxy(Action action) : base("java.lang.Runnable") public void run() => _action?.Invoke(); } + + sealed class BooleanConsumerProxy : AndroidJavaProxy + { + readonly Action _onResult; + + public BooleanConsumerProxy(Action onResult) : base("java.util.function.Consumer") + { + _onResult = onResult; + } + + public void accept(AndroidJavaObject success) + { + if (success == null) + { + _onResult?.Invoke(false); + return; + } + + _onResult?.Invoke(success.Call("booleanValue")); + } + } } #endif diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index e3f7445..632d26c 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -127,6 +127,7 @@ public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); Assert.That(apply, Does.Contain("[report clearAttachments]")); Assert.That(apply, Does.Contain("addAttachmentWithData:")); + Assert.That(apply, Does.Contain(@"fileName")); Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); Assert.That(bridge, Does.Not.Contain("_bugsee_apply_open_report")); Assert.That(bridge, Does.Not.Contain("[Bugsee createReport]")); @@ -188,6 +189,7 @@ public void Android_delete_collected_data_stops_then_deletes() string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice()"); Assert.That(invoke, Does.Contain("deleteCollectedDataOnDevice")); Assert.That(invoke, Does.Contain("true")); + Assert.That(invoke, Does.Contain("BooleanConsumerProxy")); } [Test] From 094a11d448787c8d9cdfa593f482bbcf0841078a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:30:29 +0500 Subject: [PATCH 051/157] Fence iOS upload after Stop and chain delete after stop when needed. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 31 +++++++--- Runtime/Platform/IOS/IOSBridge.cs | 63 +++++++++++++------- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 13 ++-- 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 22aab94..0038640 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -46,6 +46,8 @@ static void BugseeRunOnMain(dispatch_block_t block) typedef void (*BugseeManagedReportCreateCallback)(int succeeded); +static BOOL gCancelManagedReportUpload; + static void BugseeBridgeApplyReportDict(id report, NSDictionary *d) { if (![d isKindOfClass:[NSDictionary class]] || !report) { @@ -442,21 +444,24 @@ void _bugsee_free(char *ptr) void _bugsee_delete_collected_data(void) { BugseeRunOnMain(^{ - [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + if ([Bugsee sharedInstance] != nil) { + [Bugsee stop:^{ + [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + }]; + } else { + [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + } }); } -void _bugsee_stop_then_delete_collected_data(void) +void _bugsee_cancel_managed_report_upload(void) { - BugseeRunOnMain(^{ - [Bugsee stop:^{ - [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; - }]; - }); + gCancelManagedReportUpload = YES; } void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) { + gCancelManagedReportUpload = NO; if (!reportJson) { if (callback) { callback(0); @@ -465,6 +470,12 @@ void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCr } NSString *jsonCopy = [NSString stringWithUTF8String:reportJson]; [Bugsee createReportWithCompletion:^(BugseeExtendedReport *_Nullable report) { + if (gCancelManagedReportUpload) { + if (callback) { + callback(0); + } + return; + } if (!report) { if (callback) { callback(0); @@ -478,7 +489,9 @@ void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCr if (callback) { callback(1); } - [Bugsee uploadReport:report completion:nil]; + if (!gCancelManagedReportUpload) { + [Bugsee uploadReport:report completion:nil]; + } }]; } @@ -523,7 +536,7 @@ void _bugsee_feedback_show(void) {} char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } void _bugsee_delete_collected_data(void) {} -void _bugsee_stop_then_delete_collected_data(void) {} +void _bugsee_cancel_managed_report_upload(void) {} void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) { (void)reportJson; diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 11f7557..d3c589b 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -28,6 +28,8 @@ sealed class IOSBridge : IBugseeNativeBridge static IOSBridge _pendingManagedReportUpload; + int _reportUploadGeneration; + bool _launched; bool _blackout; IosAppearance _appearance; @@ -86,7 +88,7 @@ public void Stop(Action completion = null) _bugsee_clear_wrapper_channel(); _bugsee_stop(); _launched = false; - _pendingManagedReportUpload = null; + CancelManagedReportUpload(); _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); @@ -137,27 +139,35 @@ public void NotifyLifecycle(string eventType) public void DeleteCollectedDataOnDevice() { - if (!GetLaunched()) + if (GetLaunched()) { - _bugsee_delete_collected_data(); + if (!MainThreadDispatcher.RunSyncLifecycle(() => + { + HostLogForwarder.Uninstall(); + ExceptionPipeline.Uninstall(); + _bugsee_clear_wrapper_channel(); + CancelManagedReportUpload(); + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _launched = false; + _bugsee_delete_collected_data(); + })) + { + Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); + } + return; } - if (!MainThreadDispatcher.RunSyncLifecycle(() => - { - HostLogForwarder.Uninstall(); - ExceptionPipeline.Uninstall(); - _bugsee_clear_wrapper_channel(); - _pendingManagedReportUpload = null; - _openReport = null; - _openReportHandle = null; - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _launched = false; - _bugsee_stop_then_delete_collected_data(); - })) - { - Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); - } + _bugsee_delete_collected_data(); + } + + void CancelManagedReportUpload() + { + _reportUploadGeneration++; + _pendingManagedReportUpload = null; + _bugsee_cancel_managed_report_upload(); } public IReport CreateReport() @@ -175,6 +185,10 @@ public void DiscardReport(IReport report) throw new ArgumentNullException(nameof(report)); if (!ReferenceEquals(report, _openReportHandle)) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); + if (ReferenceEquals(_pendingManagedReportUpload, this)) + _bugsee_cancel_managed_report_upload(); + _pendingManagedReportUpload = null; + _reportUploadGeneration++; _openReport = null; _openReportHandle = null; } @@ -188,22 +202,29 @@ public void UploadReport(IReport report) if (_pendingManagedReportUpload != null) throw new InvalidOperationException("a report upload is already in progress"); var json = _openReport.ToResultJson(); + var generation = ++_reportUploadGeneration; + _pendingUploadGeneration = generation; _pendingManagedReportUpload = this; _bugsee_upload_managed_report(json, OnManagedReportCreateCompletion); } + static int _pendingUploadGeneration; + [MonoPInvokeCallback(typeof(ManagedReportUploadNativeCallback))] static void OnManagedReportCreateCompletion(int succeeded) { var bridge = _pendingManagedReportUpload; + var generation = _pendingUploadGeneration; _pendingManagedReportUpload = null; if (bridge == null) return; - bridge.FinishManagedReportUpload(succeeded != 0); + bridge.FinishManagedReportUpload(succeeded != 0, generation); } - void FinishManagedReportUpload(bool succeeded) + void FinishManagedReportUpload(bool succeeded, int generation) { + if (generation != _reportUploadGeneration) + return; _openReport = null; _openReportHandle = null; if (!succeeded) @@ -447,7 +468,7 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); - [DllImport("__Internal")] static extern void _bugsee_stop_then_delete_collected_data(); + [DllImport("__Internal")] static extern void _bugsee_cancel_managed_report_upload(); [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( string reportJson, ManagedReportUploadNativeCallback callback); diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 632d26c..2cce413 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -193,16 +193,13 @@ public void Android_delete_collected_data_stops_then_deletes() } [Test] - public void Ios_delete_when_launched_chains_stop_completion_before_delete() + public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists() { string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); - string body = ExtractNativeFunctionBody(bridge, "_bugsee_stop_then_delete_collected_data"); + string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); Assert.That(body, Does.Contain("[Bugsee stop:")); Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); - - string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); - string delete = ExtractMethodBody(ios, "public void DeleteCollectedDataOnDevice()"); - Assert.That(delete, Does.Contain("_bugsee_stop_then_delete_collected_data")); + Assert.That(body, Does.Contain("sharedInstance")); } [Test] @@ -270,13 +267,13 @@ public void Ios_create_report_guards_second_open_report() } [Test] - public void Ios_discard_report_clears_managed_slot_without_native() + public void Ios_discard_report_clears_managed_slot() { string body = ExtractMethodBody( File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), "public void DiscardReport(IReport report)"); - Assert.That(body, Does.Not.Contain("_bugsee_")); Assert.That(body, Does.Contain("_openReport = null")); + Assert.That(body, Does.Contain("_reportUploadGeneration++")); } [Test] From 39f218a32e4365dfc4c8bdf681f6649b17b79441 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:39:24 +0500 Subject: [PATCH 052/157] Cancel only the upload that was discarded, and keep attachment name and filename distinct. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 64 ++++++++++++++------ Runtime/Platform/IOS/IOSBridge.cs | 51 ++++++++-------- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 20 +++++- 3 files changed, 89 insertions(+), 46 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 0038640..eaa2b91 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -44,9 +44,24 @@ static void BugseeRunOnMain(dispatch_block_t block) } } -typedef void (*BugseeManagedReportCreateCallback)(int succeeded); +typedef void (*BugseeManagedReportCreateCallback)(int succeeded, uint64_t uploadToken); + +static uint64_t gActiveManagedReportUploadId; -static BOOL gCancelManagedReportUpload; +static BOOL BugseeManagedReportUploadStillActive(uint64_t uploadId) +{ + return uploadId != 0 && uploadId == gActiveManagedReportUploadId; +} + +static void BugseeBridgeSetAttachmentFileNameIfNeeded(id attachment, NSString *displayName, NSString *fileName) +{ + if (!attachment || fileName.length == 0 || [fileName isEqualToString:displayName]) { + return; + } + if ([attachment conformsToProtocol:@protocol(BGSAttachmentContract)]) { + ((id)attachment).fileName = fileName; + } +} static void BugseeBridgeApplyReportDict(id report, NSDictionary *d) { @@ -108,7 +123,8 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona NSString *mime = [att[@"mimeType"] isKindOfClass:[NSString class]] ? att[@"mimeType"] : @"text/plain"; id filePath = att[@"filePath"]; if ([filePath isKindOfClass:[NSString class]] && [(NSString *)filePath length] > 0) { - [report addAttachmentWithFilePath:filePath name:fileName mimeType:mime move:NO]; + id attachment = [report addAttachmentWithFilePath:filePath name:name mimeType:mime move:NO]; + BugseeBridgeSetAttachmentFileNameIfNeeded(attachment, name, fileName); continue; } NSData *data = nil; @@ -123,7 +139,8 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona if (!data) { data = [NSData data]; } - [report addAttachmentWithData:data name:fileName mimeType:mime]; + id attachment = [report addAttachmentWithData:data name:name mimeType:mime]; + BugseeBridgeSetAttachmentFileNameIfNeeded(attachment, name, fileName); } } } @@ -454,31 +471,35 @@ void _bugsee_delete_collected_data(void) }); } -void _bugsee_cancel_managed_report_upload(void) +void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { - gCancelManagedReportUpload = YES; + if (gActiveManagedReportUploadId == uploadId) { + gActiveManagedReportUploadId = 0; + } } -void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) +void _bugsee_upload_managed_report(const char *reportJson, + uint64_t uploadId, + BugseeManagedReportCreateCallback callback) { - gCancelManagedReportUpload = NO; + gActiveManagedReportUploadId = uploadId; if (!reportJson) { if (callback) { - callback(0); + callback(0, uploadId); } return; } NSString *jsonCopy = [NSString stringWithUTF8String:reportJson]; [Bugsee createReportWithCompletion:^(BugseeExtendedReport *_Nullable report) { - if (gCancelManagedReportUpload) { + if (!BugseeManagedReportUploadStillActive(uploadId)) { if (callback) { - callback(0); + callback(0, uploadId); } return; } if (!report) { if (callback) { - callback(0); + callback(0, uploadId); } return; } @@ -486,10 +507,16 @@ void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCr if ([dict isKindOfClass:[NSDictionary class]]) { BugseeBridgeApplyReportDict((id)report, dict); } + if (!BugseeManagedReportUploadStillActive(uploadId)) { + if (callback) { + callback(0, uploadId); + } + return; + } if (callback) { - callback(1); + callback(1, uploadId); } - if (!gCancelManagedReportUpload) { + if (BugseeManagedReportUploadStillActive(uploadId)) { [Bugsee uploadReport:report completion:nil]; } }]; @@ -536,12 +563,15 @@ void _bugsee_feedback_show(void) {} char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } void _bugsee_delete_collected_data(void) {} -void _bugsee_cancel_managed_report_upload(void) {} -void _bugsee_upload_managed_report(const char *reportJson, BugseeManagedReportCreateCallback callback) +void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { (void)uploadId; } +void _bugsee_upload_managed_report(const char *reportJson, + uint64_t uploadId, + BugseeManagedReportCreateCallback callback) { (void)reportJson; + (void)uploadId; if (callback) { - callback(0); + callback(0, uploadId); } } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index d3c589b..0f57f6f 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -24,10 +24,9 @@ namespace Bugsee.Platform.IOS /// sealed class IOSBridge : IBugseeNativeBridge { - delegate void ManagedReportUploadNativeCallback(int succeeded); - - static IOSBridge _pendingManagedReportUpload; + delegate void ManagedReportUploadNativeCallback(int succeeded, ulong uploadToken); + ulong _inFlightUploadToken; int _reportUploadGeneration; bool _launched; @@ -166,8 +165,11 @@ public void DeleteCollectedDataOnDevice() void CancelManagedReportUpload() { _reportUploadGeneration++; - _pendingManagedReportUpload = null; - _bugsee_cancel_managed_report_upload(); + if (_inFlightUploadToken != 0) + { + _bugsee_cancel_managed_report_upload(_inFlightUploadToken); + _inFlightUploadToken = 0; + } } public IReport CreateReport() @@ -185,9 +187,6 @@ public void DiscardReport(IReport report) throw new ArgumentNullException(nameof(report)); if (!ReferenceEquals(report, _openReportHandle)) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); - if (ReferenceEquals(_pendingManagedReportUpload, this)) - _bugsee_cancel_managed_report_upload(); - _pendingManagedReportUpload = null; _reportUploadGeneration++; _openReport = null; _openReportHandle = null; @@ -199,34 +198,31 @@ public void UploadReport(IReport report) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); if (_openReport == null) throw new InvalidOperationException("CreateReport failed."); - if (_pendingManagedReportUpload != null) - throw new InvalidOperationException("a report upload is already in progress"); var json = _openReport.ToResultJson(); - var generation = ++_reportUploadGeneration; - _pendingUploadGeneration = generation; - _pendingManagedReportUpload = this; - _bugsee_upload_managed_report(json, OnManagedReportCreateCompletion); + var uploadToken = (ulong)++_reportUploadGeneration; + _openReport = null; + _openReportHandle = null; + _uploadCompletionBridge = this; + _inFlightUploadToken = uploadToken; + _bugsee_upload_managed_report(json, uploadToken, OnManagedReportCreateCompletion); } - static int _pendingUploadGeneration; - [MonoPInvokeCallback(typeof(ManagedReportUploadNativeCallback))] - static void OnManagedReportCreateCompletion(int succeeded) + static void OnManagedReportCreateCompletion(int succeeded, ulong uploadToken) { - var bridge = _pendingManagedReportUpload; - var generation = _pendingUploadGeneration; - _pendingManagedReportUpload = null; - if (bridge == null) + // Instance is resolved through the static bridge reference set at upload time. + if (_uploadCompletionBridge == null) return; - bridge.FinishManagedReportUpload(succeeded != 0, generation); + _uploadCompletionBridge.HandleManagedReportCreateCompletion(succeeded != 0, uploadToken); } - void FinishManagedReportUpload(bool succeeded, int generation) + static IOSBridge _uploadCompletionBridge; + + void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken) { - if (generation != _reportUploadGeneration) + if (uploadToken != _inFlightUploadToken) return; - _openReport = null; - _openReportHandle = null; + _inFlightUploadToken = 0; if (!succeeded) Debug.LogError("[Bugsee] CreateReport failed."); } @@ -468,9 +464,10 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); - [DllImport("__Internal")] static extern void _bugsee_cancel_managed_report_upload(); + [DllImport("__Internal")] static extern void _bugsee_cancel_managed_report_upload(ulong uploadId); [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( string reportJson, + ulong uploadId, ManagedReportUploadNativeCallback callback); [DllImport("__Internal")] static extern void _bugsee_trace(string name, string valueJson); [DllImport("__Internal")] static extern void _bugsee_event(string name, string paramsJson); diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 2cce413..7cd069e 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -123,16 +123,32 @@ public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() Assert.That(upload, Does.Contain("createReportWithCompletion:")); Assert.That(upload, Does.Contain("uploadReport:")); Assert.That(upload, Does.Contain("BugseeExtendedReport")); + Assert.That(upload, Does.Contain("gActiveManagedReportUploadId")); + Assert.That(upload, Does.Not.Contain("gCancelManagedReportUpload")); + Assert.That(bridge, Does.Not.Contain("gCancelManagedReportUpload")); string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); Assert.That(apply, Does.Contain("[report clearAttachments]")); - Assert.That(apply, Does.Contain("addAttachmentWithData:")); - Assert.That(apply, Does.Contain(@"fileName")); + Assert.That(apply, Does.Contain("addAttachmentWithData:data name:name")); + Assert.That(apply, Does.Contain("BugseeBridgeSetAttachmentFileNameIfNeeded")); Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); Assert.That(bridge, Does.Not.Contain("_bugsee_apply_open_report")); Assert.That(bridge, Does.Not.Contain("[Bugsee createReport]")); } + [Test] + public void Ios_upload_report_clears_slot_before_native_upload() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public void UploadReport(IReport report)"); + Assert.That(body, Does.Contain("ToResultJson()")); + Assert.That(body, Does.Contain("_openReport = null")); + Assert.That(body, Does.Contain("_openReportHandle = null")); + Assert.That(body, Does.Contain("_bugsee_upload_managed_report")); + Assert.That(body, Does.Not.Contain("a report upload is already in progress")); + } + [Test] public void Ios_channel_breadcrumb_uses_exchange_factory() { From e49f3870650703740a59d0637c2ea6c0dcf8ee16 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 16:53:49 +0500 Subject: [PATCH 053/157] Let overlapping iOS managed report uploads stay independent. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 35 +++++++++++++++----- Runtime/Platform/IOS/IOSBridge.cs | 14 ++++---- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 5 +-- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index eaa2b91..4fdb341 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -46,11 +46,18 @@ static void BugseeRunOnMain(dispatch_block_t block) typedef void (*BugseeManagedReportCreateCallback)(int succeeded, uint64_t uploadToken); -static uint64_t gActiveManagedReportUploadId; +static uint64_t gManagedReportUploadFence; +static NSMutableSet *gCancelledManagedReportUploadIds; -static BOOL BugseeManagedReportUploadStillActive(uint64_t uploadId) +static BOOL BugseeManagedReportUploadStillActive(uint64_t uploadId, uint64_t uploadFence) { - return uploadId != 0 && uploadId == gActiveManagedReportUploadId; + if (uploadId == 0 || uploadFence != gManagedReportUploadFence) { + return NO; + } + if (!gCancelledManagedReportUploadIds) { + return YES; + } + return ![gCancelledManagedReportUploadIds containsObject:@(uploadId)]; } static void BugseeBridgeSetAttachmentFileNameIfNeeded(id attachment, NSString *displayName, NSString *fileName) @@ -473,16 +480,23 @@ void _bugsee_delete_collected_data(void) void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { - if (gActiveManagedReportUploadId == uploadId) { - gActiveManagedReportUploadId = 0; + if (!gCancelledManagedReportUploadIds) { + gCancelledManagedReportUploadIds = [NSMutableSet set]; } + [gCancelledManagedReportUploadIds addObject:@(uploadId)]; +} + +void _bugsee_invalidate_managed_report_uploads(void) +{ + gManagedReportUploadFence++; + [gCancelledManagedReportUploadIds removeAllObjects]; } void _bugsee_upload_managed_report(const char *reportJson, uint64_t uploadId, + uint64_t uploadFence, BugseeManagedReportCreateCallback callback) { - gActiveManagedReportUploadId = uploadId; if (!reportJson) { if (callback) { callback(0, uploadId); @@ -491,7 +505,7 @@ void _bugsee_upload_managed_report(const char *reportJson, } NSString *jsonCopy = [NSString stringWithUTF8String:reportJson]; [Bugsee createReportWithCompletion:^(BugseeExtendedReport *_Nullable report) { - if (!BugseeManagedReportUploadStillActive(uploadId)) { + if (!BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { if (callback) { callback(0, uploadId); } @@ -507,7 +521,7 @@ void _bugsee_upload_managed_report(const char *reportJson, if ([dict isKindOfClass:[NSDictionary class]]) { BugseeBridgeApplyReportDict((id)report, dict); } - if (!BugseeManagedReportUploadStillActive(uploadId)) { + if (!BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { if (callback) { callback(0, uploadId); } @@ -516,7 +530,7 @@ void _bugsee_upload_managed_report(const char *reportJson, if (callback) { callback(1, uploadId); } - if (BugseeManagedReportUploadStillActive(uploadId)) { + if (BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { [Bugsee uploadReport:report completion:nil]; } }]; @@ -564,12 +578,15 @@ void _bugsee_feedback_show(void) {} void _bugsee_free(char *ptr) { (void)ptr; } void _bugsee_delete_collected_data(void) {} void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { (void)uploadId; } +void _bugsee_invalidate_managed_report_uploads(void) {} void _bugsee_upload_managed_report(const char *reportJson, uint64_t uploadId, + uint64_t uploadFence, BugseeManagedReportCreateCallback callback) { (void)reportJson; (void)uploadId; + (void)uploadFence; if (callback) { callback(0, uploadId); } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 0f57f6f..870cd74 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -27,6 +27,7 @@ sealed class IOSBridge : IBugseeNativeBridge delegate void ManagedReportUploadNativeCallback(int succeeded, ulong uploadToken); ulong _inFlightUploadToken; + ulong _managedReportUploadFence; int _reportUploadGeneration; bool _launched; @@ -165,11 +166,9 @@ public void DeleteCollectedDataOnDevice() void CancelManagedReportUpload() { _reportUploadGeneration++; - if (_inFlightUploadToken != 0) - { - _bugsee_cancel_managed_report_upload(_inFlightUploadToken); - _inFlightUploadToken = 0; - } + _managedReportUploadFence++; + _inFlightUploadToken = 0; + _bugsee_invalidate_managed_report_uploads(); } public IReport CreateReport() @@ -200,11 +199,12 @@ public void UploadReport(IReport report) throw new InvalidOperationException("CreateReport failed."); var json = _openReport.ToResultJson(); var uploadToken = (ulong)++_reportUploadGeneration; + var uploadFence = _managedReportUploadFence; _openReport = null; _openReportHandle = null; _uploadCompletionBridge = this; _inFlightUploadToken = uploadToken; - _bugsee_upload_managed_report(json, uploadToken, OnManagedReportCreateCompletion); + _bugsee_upload_managed_report(json, uploadToken, uploadFence, OnManagedReportCreateCompletion); } [MonoPInvokeCallback(typeof(ManagedReportUploadNativeCallback))] @@ -465,9 +465,11 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); [DllImport("__Internal")] static extern void _bugsee_cancel_managed_report_upload(ulong uploadId); + [DllImport("__Internal")] static extern void _bugsee_invalidate_managed_report_uploads(); [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( string reportJson, ulong uploadId, + ulong uploadFence, ManagedReportUploadNativeCallback callback); [DllImport("__Internal")] static extern void _bugsee_trace(string name, string valueJson); [DllImport("__Internal")] static extern void _bugsee_event(string name, string paramsJson); diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 7cd069e..c216c8e 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -123,8 +123,9 @@ public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() Assert.That(upload, Does.Contain("createReportWithCompletion:")); Assert.That(upload, Does.Contain("uploadReport:")); Assert.That(upload, Does.Contain("BugseeExtendedReport")); - Assert.That(upload, Does.Contain("gActiveManagedReportUploadId")); - Assert.That(upload, Does.Not.Contain("gCancelManagedReportUpload")); + Assert.That(upload, Does.Contain("uploadFence")); + Assert.That(bridge, Does.Contain("gManagedReportUploadFence")); + Assert.That(upload, Does.Not.Contain("gActiveManagedReportUploadId")); Assert.That(bridge, Does.Not.Contain("gCancelManagedReportUpload")); string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); From 25065815eb1b788c0d82efdecc5f63eed04021e5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:03:48 +0500 Subject: [PATCH 054/157] Call the Android 7.3 report, delete, and breadcrumb methods. Co-authored-by: Cursor --- Runtime/Platform/Android/AndroidBridge.cs | 49 +++---- Runtime/Platform/Android/AndroidReport.cs | 137 ++++++++++++++++++ .../Android/Proxies/CallbackProxies.cs | 43 +++++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 33 ++++- 4 files changed, 231 insertions(+), 31 deletions(-) diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index dfc8caa..3ddbaed 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -1,7 +1,6 @@ #if UNITY_ANDROID && !UNITY_EDITOR using System; using System.Collections.Generic; -using System.Reflection; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Feedback; @@ -31,6 +30,7 @@ sealed class AndroidBridge : IBugseeNativeBridge AndroidFeedback _feedback; AndroidAppearance _appearance; IBugseeExchangeFactory _exchangeFactory; + AndroidManagedReport _openReport; static bool _loggedMissingNetworkFactory; public bool IsSupported => true; @@ -456,7 +456,7 @@ void InvokeDeleteCollectedDataOnDevice() _bugsee.CallStatic( "deleteCollectedDataOnDevice", true, - new BooleanConsumerProxy(success => + new BooleanCallback1Proxy(success => { if (!success) Debug.LogError("[Bugsee] deleteCollectedDataOnDevice failed."); @@ -465,36 +465,34 @@ void InvokeDeleteCollectedDataOnDevice() public IReport CreateReport() { - var javaReport = _bugsee.CallStatic("createReport"); - if (javaReport == null) - throw new InvalidOperationException("CreateReport failed."); - return new AndroidReport(javaReport); + if (_openReport != null) + throw new InvalidOperationException("a report is already open"); + _openReport = new AndroidManagedReport(); + return _openReport; } public void DiscardReport(IReport report) { if (report == null) throw new ArgumentNullException(nameof(report)); + if (!ReferenceEquals(report, _openReport)) + throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); + _openReport = null; } public void UploadReport(IReport report) { if (report == null) throw new ArgumentNullException(nameof(report)); - var javaReport = TryGetAndroidReportObject(report); - if (javaReport == null) - throw new ArgumentException("Report was not created by Bugsee.CreateReport on Android.", nameof(report)); - _bugsee.CallStatic("upload", javaReport); - } - - static AndroidJavaObject TryGetAndroidReportObject(IReport report) - { - if (!(report is AndroidReport androidReport)) - return null; - var field = typeof(AndroidReport).GetField( - "_report", - BindingFlags.Instance | BindingFlags.NonPublic); - return field?.GetValue(androidReport) as AndroidJavaObject; + if (!ReferenceEquals(report, _openReport)) + throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); + if (_openReport == null) + throw new InvalidOperationException("CreateReport failed."); + var snapshot = _openReport; + _openReport = null; + _bugsee.CallStatic( + "createReport", + new ReportCreationListenerProxy(snapshot, _bugsee)); } public void AddBreadcrumb(string category, string message, string levelName) @@ -505,12 +503,11 @@ public void AddBreadcrumb(string category, string message, string levelName) using (var levelClass = new AndroidJavaClass( "com.bugsee.library.contracts.exchange.Breadcrumb$Level")) - using (var def = levelClass.CallStatic("fromRawValue", (sbyte)1)) using (var javaLevel = levelClass.CallStatic( - "fromRawValue", - (sbyte)BreadcrumbLevelMap.ToAndroid(level), - def)) + "fromValue", + (sbyte)BreadcrumbLevelMap.ToAndroid(level))) using (var javaFactory = _bugsee.CallStatic("getExchangeFactory")) + using (var dataMap = new AndroidJavaObject("java.util.HashMap")) { if (javaFactory == null) return; @@ -519,7 +516,9 @@ public void AddBreadcrumb(string category, string message, string levelName) DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), category ?? "", message ?? "", - javaLevel); + javaLevel, + "manual", + dataMap); if (breadcrumb == null) return; _bugsee.CallStatic("addBreadcrumb", breadcrumb); diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index d37305f..4b2c824 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -189,5 +189,142 @@ public void SetData(string text) SetData(Encoding.UTF8.GetBytes(text)); } } + + /// Managed report snapshot; native Report is created at upload. + sealed class AndroidManagedReport : IReport + { + readonly Dictionary _attributes = new Dictionary(); + readonly List _labels = new List(); + readonly List _attachments = new List(); + + public string Id => ""; + + public IssueType Type => IssueType.Bug; + + public string Summary { get; set; } + public string Description { get; set; } + public string Email { get; set; } + + public IssueSeverity? Severity { get; set; } + + public IReadOnlyDictionary Attributes => _attributes; + + public object GetAttribute(string name) + { + if (name == null) return null; + return _attributes.TryGetValue(name, out var v) ? v : null; + } + + public void SetAttribute(string name, object value) + { + if (string.IsNullOrEmpty(name)) return; + _attributes[name] = value; + } + + public void RemoveAttribute(string name) + { + if (name == null) return; + _attributes.Remove(name); + } + + public void ClearAllAttributes() => _attributes.Clear(); + + public IReadOnlyList Labels => _labels; + + public void AddLabel(string label) + { + if (!string.IsNullOrEmpty(label)) _labels.Add(label); + } + + public void ClearLabels() => _labels.Clear(); + + public void SetLabels(IEnumerable labels) + { + _labels.Clear(); + if (labels == null) return; + foreach (var label in labels) + { + if (!string.IsNullOrEmpty(label)) _labels.Add(label); + } + } + + public IReadOnlyList Attachments => _attachments; + + public IAttachment CreateAndAddAttachment(string name) + { + var att = new AndroidManagedAttachment(name ?? "attachment"); + _attachments.Add(att); + return att; + } + + public void ClearAttachments() => _attachments.Clear(); + + internal void ApplyTo(AndroidJavaObject javaReport) + { + if (javaReport == null) + return; + + var report = new AndroidReport(javaReport); + report.Summary = Summary ?? ""; + report.Description = Description ?? ""; + report.Email = Email ?? ""; + if (Severity.HasValue) + report.Severity = Severity; + + report.ClearAllAttributes(); + foreach (var kv in _attributes) + { + if (kv.Key == null) continue; + report.SetAttribute(kv.Key, kv.Value); + } + + report.ClearLabels(); + report.SetLabels(_labels); + + report.ClearAttachments(); + for (var i = 0; i < _attachments.Count; i++) + _attachments[i].ApplyTo(report); + } + } + + sealed class AndroidManagedAttachment : IAttachment + { + byte[] _bytes; + string _text; + + public AndroidManagedAttachment(string name) + { + Name = name; + Filename = name; + MimeType = "text/plain"; + } + + public string Name { get; set; } + public string Filename { get; set; } + public string MimeType { get; set; } + + public void SetData(byte[] data) + { + _bytes = data; + _text = null; + } + + public void SetData(string text) + { + _text = text ?? ""; + _bytes = null; + } + + internal void ApplyTo(AndroidReport report) + { + var attachment = report.CreateAndAddAttachment(Name ?? "attachment"); + attachment.Filename = Filename; + attachment.MimeType = MimeType; + if (_bytes != null && _bytes.Length > 0) + attachment.SetData(_bytes); + else if (_text != null) + attachment.SetData(_text); + } + } } #endif diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index 5e7cdf6..bc39581 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -169,24 +169,57 @@ public RunnableProxy(Action action) : base("java.lang.Runnable") public void run() => _action?.Invoke(); } - sealed class BooleanConsumerProxy : AndroidJavaProxy + sealed class BooleanCallback1Proxy : AndroidJavaProxy { readonly Action _onResult; - public BooleanConsumerProxy(Action onResult) : base("java.util.function.Consumer") + public BooleanCallback1Proxy(Action onResult) + : base("com.bugsee.library.contracts.common.Callback1") { _onResult = onResult; } - public void accept(AndroidJavaObject success) + public void run(AndroidJavaObject value) { - if (success == null) + if (value == null) { _onResult?.Invoke(false); return; } - _onResult?.Invoke(success.Call("booleanValue")); + _onResult?.Invoke(value.Call("booleanValue")); + } + } + + sealed class ReportCreationListenerProxy : AndroidJavaProxy + { + readonly AndroidManagedReport _snapshot; + readonly AndroidJavaClass _bugsee; + + public ReportCreationListenerProxy(AndroidManagedReport snapshot, AndroidJavaClass bugsee) + : base("com.bugsee.library.contracts.reporting.ReportCreationListener") + { + _snapshot = snapshot; + _bugsee = bugsee; + } + + public void onCreated(AndroidJavaObject report) + { + if (report == null) + { + Debug.LogError("[Bugsee] CreateReport failed."); + return; + } + + try + { + _snapshot.ApplyTo(report); + _bugsee.CallStatic("upload", report); + } + catch (Exception ex) + { + Debug.LogException(ex); + } } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index c216c8e..71724ed 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -206,7 +206,38 @@ public void Android_delete_collected_data_stops_then_deletes() string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice()"); Assert.That(invoke, Does.Contain("deleteCollectedDataOnDevice")); Assert.That(invoke, Does.Contain("true")); - Assert.That(invoke, Does.Contain("BooleanConsumerProxy")); + Assert.That(invoke, Does.Contain("BooleanCallback1Proxy")); + + string callback = File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs")); + Assert.That(callback, Does.Contain("BooleanCallback1Proxy")); + Assert.That(callback, Does.Contain("com.bugsee.library.contracts.common.Callback1")); + Assert.That(callback, Does.Contain("public void run(AndroidJavaObject value)")); + } + + [Test] + public void Android_create_report_uses_listener_and_managed_snapshot() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + string create = ExtractMethodBody(android, "public IReport CreateReport()"); + Assert.That(create, Does.Contain("AndroidManagedReport")); + Assert.That(create, Does.Not.Contain("CallStatic(\"createReport\")")); + Assert.That(create, Does.Contain("a report is already open")); + + string upload = ExtractMethodBody(android, "public void UploadReport(IReport report)"); + Assert.That(upload, Does.Contain("ReportCreationListenerProxy")); + Assert.That(upload, Does.Contain("createReport")); + } + + [Test] + public void Android_breadcrumb_uses_fromValue_and_six_arg_factory() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")), + "public void AddBreadcrumb(string category, string message, string levelName)"); + Assert.That(body, Does.Contain("fromValue")); + Assert.That(body, Does.Not.Contain("fromRawValue")); + Assert.That(body, Does.Contain("\"manual\"")); + Assert.That(body, Does.Contain("java.util.HashMap")); } [Test] From 586646b4a75234fef8a51bf5b4344a403fa08281 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:17:36 +0500 Subject: [PATCH 055/157] Keep session attributes on an Android report and cancel an in-flight upload after stop. Co-authored-by: Cursor --- Runtime/Platform/Android/AndroidBridge.cs | 5 +- Runtime/Platform/Android/AndroidReport.cs | 77 +++++++++++++++---- .../Android/Proxies/CallbackProxies.cs | 14 +++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 29 +++++++ 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 3ddbaed..798b572 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -92,6 +92,7 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { + AndroidManagedReportUploadFence.Invalidate(); HostLogForwarder.Uninstall(); ExceptionPipeline.Uninstall(); if (completion == null) @@ -442,6 +443,7 @@ public void NotifyLifecycle(string eventType) { } public void DeleteCollectedDataOnDevice() { + AndroidManagedReportUploadFence.Invalidate(); if (GetLaunched()) { Stop(InvokeDeleteCollectedDataOnDevice); @@ -490,9 +492,10 @@ public void UploadReport(IReport report) throw new InvalidOperationException("CreateReport failed."); var snapshot = _openReport; _openReport = null; + var uploadFence = AndroidManagedReportUploadFence.Current; _bugsee.CallStatic( "createReport", - new ReportCreationListenerProxy(snapshot, _bugsee)); + new ReportCreationListenerProxy(snapshot, _bugsee, uploadFence)); } public void AddBreadcrumb(string category, string message, string levelName) diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index 4b2c824..f7f1adf 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -190,12 +190,27 @@ public void SetData(string text) } } + /// Upload generation fence; Stop/Delete invalidate in-flight createReport callbacks. + internal static class AndroidManagedReportUploadFence + { + static ulong _fence; + + public static ulong Current => _fence; + + public static void Invalidate() => checked { _fence++; } + + public static bool IsActive(ulong captured) => captured == _fence; + } + /// Managed report snapshot; native Report is created at upload. sealed class AndroidManagedReport : IReport { readonly Dictionary _attributes = new Dictionary(); readonly List _labels = new List(); readonly List _attachments = new List(); + bool _emailAssigned; + bool _attributesDirty; + bool _labelsDirty; public string Id => ""; @@ -203,7 +218,18 @@ sealed class AndroidManagedReport : IReport public string Summary { get; set; } public string Description { get; set; } - public string Email { get; set; } + + public string Email + { + get => _email; + set + { + _email = value; + _emailAssigned = true; + } + } + + string _email; public IssueSeverity? Severity { get; set; } @@ -219,28 +245,43 @@ public void SetAttribute(string name, object value) { if (string.IsNullOrEmpty(name)) return; _attributes[name] = value; + _attributesDirty = true; } public void RemoveAttribute(string name) { if (name == null) return; _attributes.Remove(name); + _attributesDirty = true; } - public void ClearAllAttributes() => _attributes.Clear(); + public void ClearAllAttributes() + { + _attributes.Clear(); + _attributesDirty = true; + } public IReadOnlyList Labels => _labels; public void AddLabel(string label) { - if (!string.IsNullOrEmpty(label)) _labels.Add(label); + if (!string.IsNullOrEmpty(label)) + { + _labels.Add(label); + _labelsDirty = true; + } } - public void ClearLabels() => _labels.Clear(); + public void ClearLabels() + { + _labels.Clear(); + _labelsDirty = true; + } public void SetLabels(IEnumerable labels) { _labels.Clear(); + _labelsDirty = true; if (labels == null) return; foreach (var label in labels) { @@ -265,23 +306,31 @@ internal void ApplyTo(AndroidJavaObject javaReport) return; var report = new AndroidReport(javaReport); - report.Summary = Summary ?? ""; - report.Description = Description ?? ""; - report.Email = Email ?? ""; + if (Summary != null) + report.Summary = Summary; + if (Description != null) + report.Description = Description; + if (_emailAssigned) + report.Email = Email; if (Severity.HasValue) report.Severity = Severity; - report.ClearAllAttributes(); - foreach (var kv in _attributes) + if (_attributesDirty) { - if (kv.Key == null) continue; - report.SetAttribute(kv.Key, kv.Value); + report.ClearAllAttributes(); + foreach (var kv in _attributes) + { + if (kv.Key == null) continue; + report.SetAttribute(kv.Key, kv.Value); + } } - report.ClearLabels(); - report.SetLabels(_labels); + if (_labelsDirty) + { + report.ClearLabels(); + report.SetLabels(_labels); + } - report.ClearAttachments(); for (var i = 0; i < _attachments.Count; i++) _attachments[i].ApplyTo(report); } diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index bc39581..ec88cb0 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -195,16 +195,24 @@ sealed class ReportCreationListenerProxy : AndroidJavaProxy { readonly AndroidManagedReport _snapshot; readonly AndroidJavaClass _bugsee; + readonly ulong _uploadFence; - public ReportCreationListenerProxy(AndroidManagedReport snapshot, AndroidJavaClass bugsee) + public ReportCreationListenerProxy( + AndroidManagedReport snapshot, + AndroidJavaClass bugsee, + ulong uploadFence) : base("com.bugsee.library.contracts.reporting.ReportCreationListener") { _snapshot = snapshot; _bugsee = bugsee; + _uploadFence = uploadFence; } public void onCreated(AndroidJavaObject report) { + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; + if (report == null) { Debug.LogError("[Bugsee] CreateReport failed."); @@ -213,7 +221,11 @@ public void onCreated(AndroidJavaObject report) try { + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; _snapshot.ApplyTo(report); + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; _bugsee.CallStatic("upload", report); } catch (Exception ex) diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 71724ed..3ffbe95 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -240,6 +240,35 @@ public void Android_breadcrumb_uses_fromValue_and_six_arg_factory() Assert.That(body, Does.Contain("java.util.HashMap")); } + [Test] + public void Android_apply_to_overlays_only_set_fields() + { + string apply = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidReport.cs")), + "internal void ApplyTo(AndroidJavaObject javaReport)"); + Assert.That(apply, Does.Contain("if (Summary != null)")); + Assert.That(apply, Does.Contain("if (_emailAssigned)")); + Assert.That(apply, Does.Contain("if (_attributesDirty)")); + Assert.That(apply, Does.Not.Contain("Email ?? \"\"")); + Assert.That(apply, Does.Not.Contain("Summary ?? \"\"")); + Assert.That(apply, Does.Not.Contain("ClearAttachments()")); + } + + [Test] + public void Android_report_upload_fenced_across_stop_and_delete() + { + string callbacks = File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs")); + string onCreated = ExtractMethodBody(callbacks, "public void onCreated(AndroidJavaObject report)"); + Assert.That(onCreated, Does.Contain("AndroidManagedReportUploadFence.IsActive")); + Assert.That(onCreated, Does.Contain("CallStatic(\"upload\"")); + + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + string stop = ExtractMethodBody(android, "public void Stop(Action completion = null)"); + Assert.That(stop, Does.Contain("AndroidManagedReportUploadFence.Invalidate")); + string delete = ExtractMethodBody(android, "public void DeleteCollectedDataOnDevice()"); + Assert.That(delete, Does.Contain("AndroidManagedReportUploadFence.Invalidate")); + } + [Test] public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists() { From 3540dc623ba537d326003854c147caa7e4c4bbe3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:29:20 +0500 Subject: [PATCH 056/157] Escape iOS report JSON and skip upload when that JSON does not parse. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 8 +- Runtime/Platform/IOS/IosReport.cs | 135 ++++++++++++++----- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 34 +++++ 3 files changed, 139 insertions(+), 38 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 4fdb341..5699ef8 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -518,9 +518,13 @@ void _bugsee_upload_managed_report(const char *reportJson, return; } NSDictionary *dict = BugseeDeserializeJson(jsonCopy.UTF8String); - if ([dict isKindOfClass:[NSDictionary class]]) { - BugseeBridgeApplyReportDict((id)report, dict); + if (![dict isKindOfClass:[NSDictionary class]]) { + if (callback) { + callback(0, uploadId); + } + return; } + BugseeBridgeApplyReportDict((id)report, dict); if (!BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { if (callback) { callback(0, uploadId); diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index fd6b095..cf84d2d 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -39,6 +39,8 @@ sealed class IosReport : IReport readonly List _attachments = new List(); readonly bool _hadAttributeArray; bool _attributesDirty; + bool _labelsDirty; + bool _attachmentsDirty; public IosReport(IosReportDto dto) { @@ -119,14 +121,23 @@ public void ClearAllAttributes() public void AddLabel(string label) { - if (!string.IsNullOrEmpty(label)) _labels.Add(label); + if (!string.IsNullOrEmpty(label)) + { + _labels.Add(label); + _labelsDirty = true; + } } - public void ClearLabels() => _labels.Clear(); + public void ClearLabels() + { + _labels.Clear(); + _labelsDirty = true; + } public void SetLabels(IEnumerable labels) { _labels.Clear(); + _labelsDirty = true; if (labels == null) return; foreach (var label in labels) { @@ -140,10 +151,15 @@ public IAttachment CreateAndAddAttachment(string name) { var att = new IosAttachment(name ?? "attachment"); _attachments.Add(att); + _attachmentsDirty = true; return att; } - public void ClearAttachments() => _attachments.Clear(); + public void ClearAttachments() + { + _attachments.Clear(); + _attachmentsDirty = true; + } public string ToResultJson() { @@ -152,62 +168,61 @@ public string ToResultJson() AppendString(sb, "summary", _dto.summary); sb.Append(','); AppendString(sb, "description", _dto.description); sb.Append(','); AppendString(sb, "email", _dto.email); sb.Append(','); - sb.Append("\"severity\":").Append(_dto.severity).Append(','); + sb.Append("\"severity\":").Append(_dto.severity); - sb.Append("\"labels\":["); - for (var i = 0; i < _labels.Count; i++) + if (_labelsDirty) { - if (i > 0) sb.Append(','); - AppendRawString(sb, _labels[i]); + sb.Append(",\"labels\":["); + for (var i = 0; i < _labels.Count; i++) + { + if (i > 0) sb.Append(','); + IosJsonStringEncoding.AppendQuoted(sb, _labels[i]); + } + sb.Append(']'); } - sb.Append("],"); // Omit attributes when normalize failed to load them and user never // touched the map — otherwise ApplyReportDict would wipe native attrs. if (_hadAttributeArray || _attributesDirty || _attributes.Count > 0) { - sb.Append("\"attributes\":{"); + sb.Append(",\"attributes\":{"); var first = true; foreach (var kv in _attributes) { if (kv.Key == null) continue; if (!first) sb.Append(','); first = false; - AppendRawString(sb, kv.Key); + IosJsonStringEncoding.AppendQuoted(sb, kv.Key); sb.Append(':'); AppendValue(sb, kv.Value); } - sb.Append("},"); + sb.Append('}'); } - sb.Append("\"attachments\":["); - for (var i = 0; i < _attachments.Count; i++) + if (_attachmentsDirty) { - if (i > 0) sb.Append(','); - sb.Append(_attachments[i].ToJsonObject()); + sb.Append(",\"attachments\":["); + for (var i = 0; i < _attachments.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append(_attachments[i].ToJsonObject()); + } + sb.Append(']'); } - sb.Append("]}"); + + sb.Append('}'); return sb.ToString(); } static void AppendString(StringBuilder sb, string key, string value) { - AppendRawString(sb, key); + IosJsonStringEncoding.AppendQuoted(sb, key); sb.Append(':'); - AppendRawString(sb, value); + IosJsonStringEncoding.AppendQuoted(sb, value); } - static void AppendRawString(StringBuilder sb, string value) - { - if (value == null) - { - sb.Append("null"); - return; - } - sb.Append('"'); - sb.Append(value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r")); - sb.Append('"'); - } + static void AppendRawString(StringBuilder sb, string value) => + IosJsonStringEncoding.AppendQuoted(sb, value); static void AppendValue(StringBuilder sb, object value) { @@ -220,7 +235,57 @@ static void AppendValue(StringBuilder sb, object value) sb.Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); return; } - AppendRawString(sb, Convert.ToString(value)); + IosJsonStringEncoding.AppendQuoted(sb, Convert.ToString(value)); + } + } + + static class IosJsonStringEncoding + { + internal static void AppendQuoted(StringBuilder sb, string value) + { + if (value == null) + { + sb.Append("null"); + return; + } + + sb.Append('"'); + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\b': + sb.Append("\\b"); + break; + case '\f': + sb.Append("\\f"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + if (c < '\u0020') + sb.Append("\\u").Append(((int)c).ToString("x4")); + else + sb.Append(c); + break; + } + } + + sb.Append('"'); } } @@ -295,11 +360,9 @@ public string ToJsonObject() void Append(string key, string value) { - sb.Append('"').Append(key).Append("\":"); - if (value == null) { sb.Append("null"); return; } - sb.Append('"') - .Append(value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r")) - .Append('"'); + IosJsonStringEncoding.AppendQuoted(sb, key); + sb.Append(':'); + IosJsonStringEncoding.AppendQuoted(sb, value); } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 3ffbe95..922714f 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -363,6 +363,40 @@ public void Ios_submit_network_uses_ToNativeMapJson_for_headers() Assert.That(body, Does.Contain("\"headers\"")); } + [Test] + public void Ios_json_encoding_escapes_controls_in_strings() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")); + string body = ExtractMethodBody(ios, "internal static void AppendQuoted(StringBuilder sb, string value)"); + Assert.That(body, Does.Contain("\\t")); + Assert.That(body, Does.Contain("\\b")); + Assert.That(body, Does.Contain("\\f")); + Assert.That(body, Does.Contain("c < '\\u0020'")); + } + + [Test] + public void Ios_to_result_json_omits_untouched_labels_and_attachments() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")), + "public string ToResultJson()"); + Assert.That(body, Does.Contain("if (_labelsDirty)")); + Assert.That(body, Does.Contain("if (_attachmentsDirty)")); + } + + [Test] + public void Ios_upload_skips_upload_when_json_deserializes_invalid() + { + string upload = ExtractMethodBody( + File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")), + "_bugsee_upload_managed_report"); + int uploadIdx = upload.IndexOf("uploadReport:", StringComparison.Ordinal); + int dictGuard = upload.IndexOf("isKindOfClass:[NSDictionary class]", StringComparison.Ordinal); + Assert.That(dictGuard, Is.GreaterThanOrEqualTo(0)); + Assert.That(uploadIdx, Is.GreaterThan(dictGuard)); + Assert.That(upload, Does.Contain("callback(0, uploadId)")); + } + [Test] public void Ios_live_report_wraps_attachment_list() { From 7e1e807abf9711abd60dad4641968b20f1417269 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:45:30 +0500 Subject: [PATCH 057/157] Write null for non-finite iOS report attribute values so the report JSON still parses. Co-authored-by: Cursor --- Runtime/Platform/IOS/IosReport.cs | 14 ++++++++- .../Bugsee.WrapperPolicy.Tests.csproj | 6 ++++ Tests~/WrapperPolicy/IosKvTestStub.cs | 8 +++++ .../IosReportNonFiniteJsonTests.cs | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 Tests~/WrapperPolicy/IosKvTestStub.cs create mode 100644 Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index cf84d2d..d1b0cad 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -228,9 +228,21 @@ static void AppendValue(StringBuilder sb, object value) { if (value == null) { sb.Append("null"); return; } if (value is bool b) { sb.Append(b ? "true" : "false"); return; } + if (value is float f) + { + if (float.IsNaN(f) || float.IsInfinity(f)) { sb.Append("null"); return; } + sb.Append(f.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return; + } + if (value is double d) + { + if (double.IsNaN(d) || double.IsInfinity(d)) { sb.Append("null"); return; } + sb.Append(d.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return; + } if (value is byte || value is sbyte || value is short || value is ushort || value is int || value is uint || value is long || value is ulong - || value is float || value is double || value is decimal) + || value is decimal) { sb.Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); return; diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 07b01b8..ad75067 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -4,6 +4,7 @@ disable false false + UNITY_IOS @@ -13,6 +14,11 @@ + + + + + diff --git a/Tests~/WrapperPolicy/IosKvTestStub.cs b/Tests~/WrapperPolicy/IosKvTestStub.cs new file mode 100644 index 0000000..e05b858 --- /dev/null +++ b/Tests~/WrapperPolicy/IosKvTestStub.cs @@ -0,0 +1,8 @@ +namespace Bugsee.Platform.IOS +{ + sealed class IosKv + { + public string key; + public string value; + } +} diff --git a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs new file mode 100644 index 0000000..8af5c42 --- /dev/null +++ b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs @@ -0,0 +1,31 @@ +using Bugsee.Platform.IOS; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IosReportNonFiniteJsonTests + { + [Test] + public void ToResultJson_writes_null_for_non_finite_attribute_numbers() + { + var report = new IosReport(new IosReportDto()); + report.SetAttribute("nan", double.NaN); + report.SetAttribute("inf", double.PositiveInfinity); + report.SetAttribute("ninf", double.NegativeInfinity); + report.SetAttribute("fnan", float.NaN); + report.SetAttribute("finf", float.PositiveInfinity); + report.SetAttribute("ok", 42.5); + + string json = report.ToResultJson(); + + Assert.That(json, Does.Contain("\"nan\":null")); + Assert.That(json, Does.Contain("\"inf\":null")); + Assert.That(json, Does.Contain("\"ninf\":null")); + Assert.That(json, Does.Contain("\"fnan\":null")); + Assert.That(json, Does.Contain("\"finf\":null")); + Assert.That(json, Does.Contain("\"ok\":42.5")); + Assert.That(json, Does.Not.Contain("NaN")); + Assert.That(json, Does.Not.Contain("Infinity")); + } + } +} From 37399a85c5b6937f06a42eba0ea7ef9cc12ed862 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 17:59:20 +0500 Subject: [PATCH 058/157] Release an Android report on stop and omit unset iOS severity. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 5 ++++- Runtime/Platform/Android/AndroidBridge.cs | 2 ++ Runtime/Platform/IOS/IosReport.cs | 8 ++++++-- .../IosReportNonFiniteJsonTests.cs | 15 ++++++++++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 20 +++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 5699ef8..68de1cc 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -90,7 +90,10 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona } id sev = d[@"severity"]; if ([sev respondsToSelector:@selector(integerValue)]) { - report.severity = (BugseeSeverityLevel)[sev integerValue]; + NSInteger sevVal = [sev integerValue]; + if (sevVal != 0) { + report.severity = (BugseeSeverityLevel)sevVal; + } } id labels = d[@"labels"]; diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 798b572..1b77089 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -93,6 +93,7 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { AndroidManagedReportUploadFence.Invalidate(); + _openReport = null; HostLogForwarder.Uninstall(); ExceptionPipeline.Uninstall(); if (completion == null) @@ -444,6 +445,7 @@ public void NotifyLifecycle(string eventType) { } public void DeleteCollectedDataOnDevice() { AndroidManagedReportUploadFence.Invalidate(); + _openReport = null; if (GetLaunched()) { Stop(InvokeDeleteCollectedDataOnDevice); diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index d1b0cad..9886547 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -167,8 +167,12 @@ public string ToResultJson() sb.Append('{'); AppendString(sb, "summary", _dto.summary); sb.Append(','); AppendString(sb, "description", _dto.description); sb.Append(','); - AppendString(sb, "email", _dto.email); sb.Append(','); - sb.Append("\"severity\":").Append(_dto.severity); + AppendString(sb, "email", _dto.email); + if (_dto.severity != 0) + { + sb.Append(','); + sb.Append("\"severity\":").Append(_dto.severity); + } if (_labelsDirty) { diff --git a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs index 8af5c42..eec9d21 100644 --- a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs +++ b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs @@ -1,3 +1,4 @@ +using Bugsee.Contracts.Options; using Bugsee.Platform.IOS; using NUnit.Framework; @@ -27,5 +28,19 @@ public void ToResultJson_writes_null_for_non_finite_attribute_numbers() Assert.That(json, Does.Not.Contain("NaN")); Assert.That(json, Does.Not.Contain("Infinity")); } + + [Test] + public void ToResultJson_omits_severity_when_unset_zero() + { + var report = new IosReport(new IosReportDto()); + string json = report.ToResultJson(); + + Assert.That(json, Does.Not.Contain("\"severity\"")); + + report.Severity = IssueSeverity.High; + json = report.ToResultJson(); + Assert.That(json, Does.Contain("\"severity\":")); + Assert.That(json, Does.Contain("\"severity\":" + (int)IssueSeverity.High)); + } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 922714f..7693929 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -265,8 +265,28 @@ public void Android_report_upload_fenced_across_stop_and_delete() string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); string stop = ExtractMethodBody(android, "public void Stop(Action completion = null)"); Assert.That(stop, Does.Contain("AndroidManagedReportUploadFence.Invalidate")); + Assert.That(stop, Does.Contain("_openReport = null")); string delete = ExtractMethodBody(android, "public void DeleteCollectedDataOnDevice()"); Assert.That(delete, Does.Contain("AndroidManagedReportUploadFence.Invalidate")); + Assert.That(delete, Does.Contain("_openReport = null")); + } + + [Test] + public void Ios_apply_report_dict_skips_unset_severity_zero() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); + Assert.That(body, Does.Contain("sevVal != 0")); + Assert.That(body, Does.Not.Match("report\\.severity\\s*=.*integerValue\\]")); + } + + [Test] + public void Ios_to_result_json_omits_unset_severity_zero() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")), + "public string ToResultJson()"); + Assert.That(body, Does.Contain("if (_dto.severity != 0)")); } [Test] From 951262cb9878c995cc3065ef57f5bbd6539d96e4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 18:10:07 +0500 Subject: [PATCH 059/157] Clear native Android attachments when the managed report snapshot is dirty. Co-authored-by: Cursor --- Runtime/Platform/Android/AndroidReport.cs | 26 ++++++++++++++++---- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index f7f1adf..c848886 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -211,6 +211,7 @@ sealed class AndroidManagedReport : IReport bool _emailAssigned; bool _attributesDirty; bool _labelsDirty; + bool _attachmentsDirty; public string Id => ""; @@ -293,12 +294,19 @@ public void SetLabels(IEnumerable labels) public IAttachment CreateAndAddAttachment(string name) { - var att = new AndroidManagedAttachment(name ?? "attachment"); + var att = new AndroidManagedAttachment(name ?? "attachment", MarkAttachmentsDirty); _attachments.Add(att); + _attachmentsDirty = true; return att; } - public void ClearAttachments() => _attachments.Clear(); + public void ClearAttachments() + { + _attachments.Clear(); + _attachmentsDirty = true; + } + + void MarkAttachmentsDirty() => _attachmentsDirty = true; internal void ApplyTo(AndroidJavaObject javaReport) { @@ -331,18 +339,24 @@ internal void ApplyTo(AndroidJavaObject javaReport) report.SetLabels(_labels); } - for (var i = 0; i < _attachments.Count; i++) - _attachments[i].ApplyTo(report); + if (_attachmentsDirty) + { + report.ClearAttachments(); + for (var i = 0; i < _attachments.Count; i++) + _attachments[i].ApplyTo(report); + } } } sealed class AndroidManagedAttachment : IAttachment { + readonly Action _markDirty; byte[] _bytes; string _text; - public AndroidManagedAttachment(string name) + public AndroidManagedAttachment(string name, Action markDirty) { + _markDirty = markDirty; Name = name; Filename = name; MimeType = "text/plain"; @@ -356,12 +370,14 @@ public void SetData(byte[] data) { _bytes = data; _text = null; + _markDirty?.Invoke(); } public void SetData(string text) { _text = text ?? ""; _bytes = null; + _markDirty?.Invoke(); } internal void ApplyTo(AndroidReport report) diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 7693929..4f57589 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -249,9 +249,10 @@ public void Android_apply_to_overlays_only_set_fields() Assert.That(apply, Does.Contain("if (Summary != null)")); Assert.That(apply, Does.Contain("if (_emailAssigned)")); Assert.That(apply, Does.Contain("if (_attributesDirty)")); + Assert.That(apply, Does.Contain("if (_attachmentsDirty)")); + Assert.That(apply, Does.Contain("report.ClearAttachments()")); Assert.That(apply, Does.Not.Contain("Email ?? \"\"")); Assert.That(apply, Does.Not.Contain("Summary ?? \"\"")); - Assert.That(apply, Does.Not.Contain("ClearAttachments()")); } [Test] From ffb17b9805fb16f522afd1f8af082f615e13da77 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 18:24:10 +0500 Subject: [PATCH 060/157] Overlay Android report attributes per key unless the caller cleared all. Co-authored-by: Cursor --- Runtime/Platform/Android/AndroidReport.cs | 26 ++++++++++++++++---- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index c848886..dda51f3 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -206,10 +206,12 @@ internal static class AndroidManagedReportUploadFence sealed class AndroidManagedReport : IReport { readonly Dictionary _attributes = new Dictionary(); + readonly HashSet _removedAttributes = new HashSet(); readonly List _labels = new List(); readonly List _attachments = new List(); bool _emailAssigned; - bool _attributesDirty; + bool _attributesOverlayDirty; + bool _attributesClearAll; bool _labelsDirty; bool _attachmentsDirty; @@ -246,20 +248,24 @@ public void SetAttribute(string name, object value) { if (string.IsNullOrEmpty(name)) return; _attributes[name] = value; - _attributesDirty = true; + _removedAttributes.Remove(name); + _attributesOverlayDirty = true; } public void RemoveAttribute(string name) { if (name == null) return; _attributes.Remove(name); - _attributesDirty = true; + _removedAttributes.Add(name); + _attributesOverlayDirty = true; } public void ClearAllAttributes() { _attributes.Clear(); - _attributesDirty = true; + _removedAttributes.Clear(); + _attributesClearAll = true; + _attributesOverlayDirty = false; } public IReadOnlyList Labels => _labels; @@ -323,7 +329,7 @@ internal void ApplyTo(AndroidJavaObject javaReport) if (Severity.HasValue) report.Severity = Severity; - if (_attributesDirty) + if (_attributesClearAll) { report.ClearAllAttributes(); foreach (var kv in _attributes) @@ -332,6 +338,16 @@ internal void ApplyTo(AndroidJavaObject javaReport) report.SetAttribute(kv.Key, kv.Value); } } + else if (_attributesOverlayDirty) + { + foreach (var name in _removedAttributes) + report.RemoveAttribute(name); + foreach (var kv in _attributes) + { + if (kv.Key == null) continue; + report.SetAttribute(kv.Key, kv.Value); + } + } if (_labelsDirty) { diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 4f57589..e4e391c 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -248,7 +248,8 @@ public void Android_apply_to_overlays_only_set_fields() "internal void ApplyTo(AndroidJavaObject javaReport)"); Assert.That(apply, Does.Contain("if (Summary != null)")); Assert.That(apply, Does.Contain("if (_emailAssigned)")); - Assert.That(apply, Does.Contain("if (_attributesDirty)")); + Assert.That(apply, Does.Contain("if (_attributesClearAll)")); + Assert.That(apply, Does.Contain("else if (_attributesOverlayDirty)")); Assert.That(apply, Does.Contain("if (_attachmentsDirty)")); Assert.That(apply, Does.Contain("report.ClearAttachments()")); Assert.That(apply, Does.Not.Contain("Email ?? \"\"")); From 1ae751d92957d759fe01ad90a15b9a88e0a0150c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 18:35:36 +0500 Subject: [PATCH 061/157] Overlay iOS CreateReport attributes per key unless the caller cleared all. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 18 +++++- Plugins/iOS/BugseeUnityCallbacks.mm | 18 +++++- Runtime/Platform/IOS/IosReport.cs | 57 ++++++++++++++----- .../IosReportNonFiniteJsonTests.cs | 20 +++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 20 +++++++ 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 68de1cc..d49e9ae 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -106,14 +106,30 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona } } + id removals = d[@"attributeRemovals"]; + if ([removals isKindOfClass:[NSArray class]]) { + for (id name in (NSArray *)removals) { + if ([name isKindOfClass:[NSString class]] && [report respondsToSelector:@selector(clearAttribute:)]) { + [report clearAttribute:(NSString *)name]; + } + } + } + + id replaceAll = d[@"attributesReplaceAll"]; + BOOL shouldReplaceAll = [replaceAll respondsToSelector:@selector(boolValue)] && [replaceAll boolValue]; + id attrs = d[@"attributes"]; if ([attrs isKindOfClass:[NSDictionary class]]) { - [report clearAllAttributes]; + if (shouldReplaceAll) { + [report clearAllAttributes]; + } [(NSDictionary *)attrs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { if ([key isKindOfClass:[NSString class]]) { [report setAttribute:obj forName:key]; } }]; + } else if (shouldReplaceAll) { + [report clearAllAttributes]; } id attachments = d[@"attachments"]; diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index dc0351b..41c7eed 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -251,14 +251,30 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar } } + id removals = d[@"attributeRemovals"]; + if ([removals isKindOfClass:[NSArray class]]) { + for (id name in (NSArray *)removals) { + if ([name isKindOfClass:[NSString class]] && [report respondsToSelector:@selector(clearAttribute:)]) { + [report clearAttribute:(NSString *)name]; + } + } + } + + id replaceAll = d[@"attributesReplaceAll"]; + BOOL shouldReplaceAll = [replaceAll respondsToSelector:@selector(boolValue)] && [replaceAll boolValue]; + id attrs = d[@"attributes"]; if ([attrs isKindOfClass:[NSDictionary class]]) { - [report clearAllAttributes]; + if (shouldReplaceAll) { + [report clearAllAttributes]; + } [(NSDictionary *)attrs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { if ([key isKindOfClass:[NSString class]]) { [report setAttribute:obj forName:key]; } }]; + } else if (shouldReplaceAll) { + [report clearAllAttributes]; } id attachments = d[@"attachments"]; diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index 9886547..ce3552a 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -35,10 +35,12 @@ sealed class IosReport : IReport { readonly IosReportDto _dto; readonly Dictionary _attributes = new Dictionary(); + readonly HashSet _removedAttributes = new HashSet(); readonly List _labels = new List(); readonly List _attachments = new List(); readonly bool _hadAttributeArray; - bool _attributesDirty; + bool _attributesOverlayDirty; + bool _attributesClearAll; bool _labelsDirty; bool _attachmentsDirty; @@ -101,20 +103,24 @@ public void SetAttribute(string name, object value) { if (string.IsNullOrEmpty(name)) return; _attributes[name] = value; - _attributesDirty = true; + _removedAttributes.Remove(name); + _attributesOverlayDirty = true; } public void RemoveAttribute(string name) { if (name == null) return; _attributes.Remove(name); - _attributesDirty = true; + _removedAttributes.Add(name); + _attributesOverlayDirty = true; } public void ClearAllAttributes() { _attributes.Clear(); - _attributesDirty = true; + _removedAttributes.Clear(); + _attributesClearAll = true; + _attributesOverlayDirty = false; } public IReadOnlyList Labels => _labels; @@ -187,20 +193,41 @@ public string ToResultJson() // Omit attributes when normalize failed to load them and user never // touched the map — otherwise ApplyReportDict would wipe native attrs. - if (_hadAttributeArray || _attributesDirty || _attributes.Count > 0) + var emitAttributes = _hadAttributeArray || _attributesClearAll || _attributesOverlayDirty + || _attributes.Count > 0 || _removedAttributes.Count > 0; + if (emitAttributes) { - sb.Append(",\"attributes\":{"); - var first = true; - foreach (var kv in _attributes) + if (!_hadAttributeArray && _removedAttributes.Count > 0) { - if (kv.Key == null) continue; - if (!first) sb.Append(','); - first = false; - IosJsonStringEncoding.AppendQuoted(sb, kv.Key); - sb.Append(':'); - AppendValue(sb, kv.Value); + sb.Append(",\"attributeRemovals\":["); + var firstRemoval = true; + foreach (var name in _removedAttributes) + { + if (!firstRemoval) sb.Append(','); + firstRemoval = false; + IosJsonStringEncoding.AppendQuoted(sb, name); + } + sb.Append(']'); } - sb.Append('}'); + + if (_attributes.Count > 0 || _attributesClearAll || _hadAttributeArray) + { + sb.Append(",\"attributes\":{"); + var first = true; + foreach (var kv in _attributes) + { + if (kv.Key == null) continue; + if (!first) sb.Append(','); + first = false; + IosJsonStringEncoding.AppendQuoted(sb, kv.Key); + sb.Append(':'); + AppendValue(sb, kv.Value); + } + sb.Append('}'); + } + + if (_attributesClearAll || _hadAttributeArray) + sb.Append(",\"attributesReplaceAll\":true"); } if (_attachmentsDirty) diff --git a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs index eec9d21..6b57365 100644 --- a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs +++ b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs @@ -42,5 +42,25 @@ public void ToResultJson_omits_severity_when_unset_zero() Assert.That(json, Does.Contain("\"severity\":")); Assert.That(json, Does.Contain("\"severity\":" + (int)IssueSeverity.High)); } + + [Test] + public void ToResultJson_overlays_create_report_attributes_without_replace_all() + { + var report = new IosReport(new IosReportDto()); + report.SetAttribute("cart_id", "abc"); + string json = report.ToResultJson(); + + Assert.That(json, Does.Contain("\"cart_id\":\"abc\"")); + Assert.That(json, Does.Not.Contain("attributesReplaceAll")); + + report.RemoveAttribute("cart_id"); + json = report.ToResultJson(); + Assert.That(json, Does.Contain("\"attributeRemovals\":[\"cart_id\"]")); + Assert.That(json, Does.Not.Contain("attributesReplaceAll")); + + report.ClearAllAttributes(); + json = report.ToResultJson(); + Assert.That(json, Does.Contain("attributesReplaceAll")); + } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index e4e391c..35eb716 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -282,6 +282,16 @@ public void Ios_apply_report_dict_skips_unset_severity_zero() Assert.That(body, Does.Not.Match("report\\.severity\\s*=.*integerValue\\]")); } + [Test] + public void Ios_apply_report_dict_overlays_attributes_without_default_clear() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); + Assert.That(body, Does.Contain("attributeRemovals")); + Assert.That(body, Does.Contain("attributesReplaceAll")); + Assert.That(body, Does.Contain("shouldReplaceAll")); + } + [Test] public void Ios_to_result_json_omits_unset_severity_zero() { @@ -291,6 +301,16 @@ public void Ios_to_result_json_omits_unset_severity_zero() Assert.That(body, Does.Contain("if (_dto.severity != 0)")); } + [Test] + public void Ios_to_result_json_emits_attribute_overlay_metadata() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")), + "public string ToResultJson()"); + Assert.That(body, Does.Contain("attributeRemovals")); + Assert.That(body, Does.Contain("attributesReplaceAll")); + } + [Test] public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists() { From a712540a20c330fa99497556ea182fd476f2fa0a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 18:49:37 +0500 Subject: [PATCH 062/157] Apply managed iOS uploads with BugseeExtendedReport APIs. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 38 ++++++++------------ Plugins/iOS/BugseeUnityCallbacks.mm | 4 +-- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 14 ++++---- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index d49e9ae..b6703ad 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -70,7 +70,7 @@ static void BugseeBridgeSetAttachmentFileNameIfNeeded(id attachment, NSString *d } } -static void BugseeBridgeApplyReportDict(id report, NSDictionary *d) +static void BugseeBridgeApplyExtendedReportDict(BugseeExtendedReport *report, NSDictionary *d) { if (![d isKindOfClass:[NSDictionary class]] || !report) { return; @@ -78,38 +78,35 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona id summary = d[@"summary"]; if ([summary isKindOfClass:[NSString class]]) { - report.summary = summary; + [report setSummary:summary]; } id desc = d[@"description"]; if ([desc isKindOfClass:[NSString class]]) { - report.reportDescription = desc; - } - id email = d[@"email"]; - if ([email isKindOfClass:[NSString class]]) { - report.email = email; + [report setDescription:desc]; } id sev = d[@"severity"]; if ([sev respondsToSelector:@selector(integerValue)]) { NSInteger sevVal = [sev integerValue]; if (sevVal != 0) { - report.severity = (BugseeSeverityLevel)sevVal; + [report setSeverity:(BugseeSeverityLevel)sevVal]; } } id labels = d[@"labels"]; if ([labels isKindOfClass:[NSArray class]]) { - [report clearLabels]; + NSMutableArray *clean = [NSMutableArray array]; for (id label in (NSArray *)labels) { if ([label isKindOfClass:[NSString class]]) { - [report addLabel:label]; + [clean addObject:label]; } } + report.labels = clean; } id removals = d[@"attributeRemovals"]; if ([removals isKindOfClass:[NSArray class]]) { for (id name in (NSArray *)removals) { - if ([name isKindOfClass:[NSString class]] && [report respondsToSelector:@selector(clearAttribute:)]) { + if ([name isKindOfClass:[NSString class]]) { [report clearAttribute:(NSString *)name]; } } @@ -125,7 +122,7 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona } [(NSDictionary *)attrs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { if ([key isKindOfClass:[NSString class]]) { - [report setAttribute:obj forName:key]; + [report setAttribute:(NSString *)key withValue:obj]; } }]; } else if (shouldReplaceAll) { @@ -134,7 +131,7 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona id attachments = d[@"attachments"]; if ([attachments isKindOfClass:[NSArray class]]) { - [report clearAttachments]; + [report clearAllAttachments]; for (id item in (NSArray *)attachments) { if (![item isKindOfClass:[NSDictionary class]]) { continue; @@ -146,13 +143,6 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona if (fileName.length == 0) { fileName = name; } - NSString *mime = [att[@"mimeType"] isKindOfClass:[NSString class]] ? att[@"mimeType"] : @"text/plain"; - id filePath = att[@"filePath"]; - if ([filePath isKindOfClass:[NSString class]] && [(NSString *)filePath length] > 0) { - id attachment = [report addAttachmentWithFilePath:filePath name:name mimeType:mime move:NO]; - BugseeBridgeSetAttachmentFileNameIfNeeded(attachment, name, fileName); - continue; - } NSData *data = nil; id b64 = att[@"dataBase64"]; if ([b64 isKindOfClass:[NSString class]] && [(NSString *)b64 length] > 0) { @@ -165,8 +155,10 @@ static void BugseeBridgeApplyReportDict(id report, NSDictiona if (!data) { data = [NSData data]; } - id attachment = [report addAttachmentWithData:data name:name mimeType:mime]; - BugseeBridgeSetAttachmentFileNameIfNeeded(attachment, name, fileName); + BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:name filename:fileName data:data]; + if (attachment) { + [report setAttachment:attachment]; + } } } } @@ -543,7 +535,7 @@ void _bugsee_upload_managed_report(const char *reportJson, } return; } - BugseeBridgeApplyReportDict((id)report, dict); + BugseeBridgeApplyExtendedReportDict(report, dict); if (!BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { if (callback) { callback(0, uploadId); diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 41c7eed..3d24522 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -254,8 +254,8 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar id removals = d[@"attributeRemovals"]; if ([removals isKindOfClass:[NSArray class]]) { for (id name in (NSArray *)removals) { - if ([name isKindOfClass:[NSString class]] && [report respondsToSelector:@selector(clearAttribute:)]) { - [report clearAttribute:(NSString *)name]; + if ([name isKindOfClass:[NSString class]]) { + [report removeAttributeForName:(NSString *)name]; } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 35eb716..ba3b6b9 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -128,10 +128,10 @@ public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() Assert.That(upload, Does.Not.Contain("gActiveManagedReportUploadId")); Assert.That(bridge, Does.Not.Contain("gCancelManagedReportUpload")); - string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); - Assert.That(apply, Does.Contain("[report clearAttachments]")); - Assert.That(apply, Does.Contain("addAttachmentWithData:data name:name")); - Assert.That(apply, Does.Contain("BugseeBridgeSetAttachmentFileNameIfNeeded")); + string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyExtendedReportDict"); + Assert.That(apply, Does.Contain("[report clearAllAttachments]")); + Assert.That(apply, Does.Contain("[BugseeAttachment attachmentWithName:name filename:fileName data:data]")); + Assert.That(apply, Does.Contain("[report setAttachment:attachment]")); Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); Assert.That(bridge, Does.Not.Contain("_bugsee_apply_open_report")); Assert.That(bridge, Does.Not.Contain("[Bugsee createReport]")); @@ -277,7 +277,7 @@ public void Android_report_upload_fenced_across_stop_and_delete() public void Ios_apply_report_dict_skips_unset_severity_zero() { string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); - string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyExtendedReportDict"); Assert.That(body, Does.Contain("sevVal != 0")); Assert.That(body, Does.Not.Match("report\\.severity\\s*=.*integerValue\\]")); } @@ -286,10 +286,12 @@ public void Ios_apply_report_dict_skips_unset_severity_zero() public void Ios_apply_report_dict_overlays_attributes_without_default_clear() { string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); - string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyReportDict"); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyExtendedReportDict"); Assert.That(body, Does.Contain("attributeRemovals")); Assert.That(body, Does.Contain("attributesReplaceAll")); Assert.That(body, Does.Contain("shouldReplaceAll")); + Assert.That(body, Does.Contain("setAttribute:(NSString *)key withValue:obj")); + Assert.That(body, Does.Contain("clearAllAttachments")); } [Test] From 3d9fb38be2e924dcad37151d2f1acf3439129324 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 19:02:26 +0500 Subject: [PATCH 063/157] Warn on unsupported iOS CreateReport email and clear handler attachments. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityCallbacks.mm | 1 + Runtime/Platform/IOS/IOSBridge.cs | 5 +++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 17 +++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 3d24522..dc385f9 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -279,6 +279,7 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar id attachments = d[@"attachments"]; if ([attachments isKindOfClass:[NSArray class]]) { + [report clearAttachments]; for (id item in (NSArray *)attachments) { if (![item isKindOfClass:[NSDictionary class]]) continue; NSDictionary *att = (NSDictionary *)item; diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 870cd74..b3a0a74 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -197,6 +197,11 @@ public void UploadReport(IReport report) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); if (_openReport == null) throw new InvalidOperationException("CreateReport failed."); + if (!string.IsNullOrEmpty(_openReport.Email)) + { + Debug.LogWarning( + "[Bugsee] Email on CreateReport is not supported on iOS managed upload; use session identity APIs."); + } var json = _openReport.ToResultJson(); var uploadToken = (ulong)++_reportUploadGeneration; var uploadFence = _managedReportUploadFence; diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index ba3b6b9..78cd0a6 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -313,6 +313,23 @@ public void Ios_to_result_json_emits_attribute_overlay_metadata() Assert.That(body, Does.Contain("attributesReplaceAll")); } + [Test] + public void Ios_upload_report_warns_when_create_report_email_set() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public void UploadReport(IReport report)"); + Assert.That(body, Does.Contain("Email on CreateReport is not supported on iOS managed upload")); + } + + [Test] + public void Ios_handler_apply_clears_attachments_before_add_loop() + { + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + string body = ExtractNativeFunctionBody(callbacks, "BugseeUnityApplyReportDict"); + Assert.That(body, Does.Contain("[report clearAttachments]")); + } + [Test] public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists() { From 277c84be096981dc22e353657d01156288da070c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 19:15:15 +0500 Subject: [PATCH 064/157] Clear the iOS CreateReport slot on stopped-path delete. Co-authored-by: Cursor --- Runtime/Platform/IOS/IOSBridge.cs | 3 +++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index b3a0a74..5621d1d 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -160,6 +160,9 @@ public void DeleteCollectedDataOnDevice() return; } + CancelManagedReportUpload(); + _openReport = null; + _openReportHandle = null; _bugsee_delete_collected_data(); } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 78cd0a6..ec5e11f 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -414,6 +414,16 @@ public void Ios_discard_report_clears_managed_slot() Assert.That(body, Does.Contain("_reportUploadGeneration++")); } + [Test] + public void Ios_delete_clears_open_report_slot_when_stopped() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public void DeleteCollectedDataOnDevice()"); + Assert.That(body, Does.Contain("_openReport = null")); + Assert.That(body, Does.Contain("_openReportHandle = null")); + } + [Test] public void Ios_submit_network_uses_ToNativeMapJson_for_headers() { From b3d965b3ecc542a7d6f5ac961ab7308680c9c4b3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 19:27:17 +0500 Subject: [PATCH 065/157] Append handler attachments unless the caller cleared them. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityCallbacks.mm | 7 +++++- Runtime/Platform/IOS/IosReport.cs | 4 +++ .../IosReportNonFiniteJsonTests.cs | 17 +++++++++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 25 +++++++++++++++++-- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index dc385f9..6e23cb8 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -277,9 +277,14 @@ static void BugseeUnityApplyReportDict(id report, NSDictionar [report clearAllAttributes]; } + id replaceAttachments = d[@"attachmentsReplaceAll"]; + BOOL shouldReplaceAttachments = [replaceAttachments respondsToSelector:@selector(boolValue)] && [replaceAttachments boolValue]; + id attachments = d[@"attachments"]; if ([attachments isKindOfClass:[NSArray class]]) { - [report clearAttachments]; + if (shouldReplaceAttachments) { + [report clearAttachments]; + } for (id item in (NSArray *)attachments) { if (![item isKindOfClass:[NSDictionary class]]) continue; NSDictionary *att = (NSDictionary *)item; diff --git a/Runtime/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index ce3552a..0613b19 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -43,6 +43,7 @@ sealed class IosReport : IReport bool _attributesClearAll; bool _labelsDirty; bool _attachmentsDirty; + bool _attachmentsReplaceAll; public IosReport(IosReportDto dto) { @@ -165,6 +166,7 @@ public void ClearAttachments() { _attachments.Clear(); _attachmentsDirty = true; + _attachmentsReplaceAll = true; } public string ToResultJson() @@ -239,6 +241,8 @@ public string ToResultJson() sb.Append(_attachments[i].ToJsonObject()); } sb.Append(']'); + if (_attachmentsReplaceAll) + sb.Append(",\"attachmentsReplaceAll\":true"); } sb.Append('}'); diff --git a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs index 6b57365..082773a 100644 --- a/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs +++ b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs @@ -62,5 +62,22 @@ public void ToResultJson_overlays_create_report_attributes_without_replace_all() json = report.ToResultJson(); Assert.That(json, Does.Contain("attributesReplaceAll")); } + + [Test] + public void ToResultJson_appends_attachments_without_replace_all_until_clear() + { + var report = new IosReport(new IosReportDto()); + var att = report.CreateAndAddAttachment("log.txt"); + att.SetData("hello"); + string json = report.ToResultJson(); + + Assert.That(json, Does.Contain("\"attachments\":[")); + Assert.That(json, Does.Not.Contain("attachmentsReplaceAll")); + + report.ClearAttachments(); + json = report.ToResultJson(); + Assert.That(json, Does.Contain("\"attachments\":[]")); + Assert.That(json, Does.Contain("attachmentsReplaceAll")); + } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index ec5e11f..de1d0f5 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -323,11 +323,32 @@ public void Ios_upload_report_warns_when_create_report_email_set() } [Test] - public void Ios_handler_apply_clears_attachments_before_add_loop() + public void Ios_handler_apply_appends_attachments_unless_replace_all_flag() { string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); string body = ExtractNativeFunctionBody(callbacks, "BugseeUnityApplyReportDict"); - Assert.That(body, Does.Contain("[report clearAttachments]")); + Assert.That(body, Does.Contain("attachmentsReplaceAll")); + Assert.That(body, Does.Contain("shouldReplaceAttachments")); + Assert.That(body, Does.Contain("if (shouldReplaceAttachments)")); + } + + [Test] + public void Ios_to_result_json_emits_attachments_replace_all_only_after_clear() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")), + "public string ToResultJson()"); + Assert.That(body, Does.Contain("_attachmentsReplaceAll")); + Assert.That(body, Does.Contain("attachmentsReplaceAll")); + } + + [Test] + public void Ios_create_report_apply_still_clears_attachments() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyExtendedReportDict"); + Assert.That(body, Does.Contain("[report clearAllAttachments]")); + Assert.That(body, Does.Not.Contain("attachmentsReplaceAll")); } [Test] From e461acdb6b67f96f7d497051e5585dfb2f1bcf68 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 19:45:03 +0500 Subject: [PATCH 066/157] Keep a delete from wiping the next launch, and buffer iOS breadcrumbs until launch. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 21 ++++++-- Runtime/Platform/Android/AndroidBridge.cs | 12 +++-- Runtime/Platform/IOS/IOSBridge.cs | 44 ++++++++++++++--- .../DeleteCollectedDataLaunchGeneration.cs | 19 +++++++ ...eleteCollectedDataLaunchGeneration.cs.meta | 11 +++++ Runtime/WrapperPolicy/IosPendingBreadcrumb.cs | 9 ++++ .../IosPendingBreadcrumb.cs.meta | 11 +++++ .../NetworkEventLaunchBufferTests.cs | 49 +++++++++++++++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 49 ++++++++++++++++++- 9 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs create mode 100644 Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs.meta create mode 100644 Runtime/WrapperPolicy/IosPendingBreadcrumb.cs create mode 100644 Runtime/WrapperPolicy/IosPendingBreadcrumb.cs.meta diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index b6703ad..a4c03cd 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -476,15 +476,23 @@ void _bugsee_free(char *ptr) } } -void _bugsee_delete_collected_data(void) +typedef int (*BugseeDeleteCollectedDataShouldRunFn)(int capturedGeneration); + +void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) { BugseeRunOnMain(^{ + void (^runDeleteIfAllowed)(void) = ^{ + if (shouldRun && !shouldRun(capturedGeneration)) { + return; + } + [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + }; if ([Bugsee sharedInstance] != nil) { [Bugsee stop:^{ - [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + runDeleteIfAllowed(); }]; } else { - [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + runDeleteIfAllowed(); } }); } @@ -556,6 +564,7 @@ void _bugsee_upload_managed_report(const char *reportJson, #else // !BUGSEE_IOS_SDK typedef void (*BugseeManagedReportCreateCallback)(int succeeded); +typedef int (*BugseeDeleteCollectedDataShouldRunFn)(int capturedGeneration); extern "C" { @@ -591,7 +600,11 @@ void _bugsee_feedback_show(void) {} void _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { (void)propertyName; (void)propertyValue; } char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } -void _bugsee_delete_collected_data(void) {} +void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) +{ + (void)capturedGeneration; + (void)shouldRun; +} void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { (void)uploadId; } void _bugsee_invalidate_managed_report_uploads(void) {} void _bugsee_upload_managed_report(const char *reportJson, diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 1b77089..3b0adc5 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -69,6 +69,7 @@ AndroidJavaObject CurrentActivity() public void Launch(string appToken, IDictionary options) { ManagedExceptionPayload.EnsureBuildIdentity(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); EnsureWrapperRegistered(); InstallDefaultListeners(); using (var activity = CurrentActivity()) @@ -82,6 +83,7 @@ public void Launch(string appToken, IDictionary options) public void Relaunch(IDictionary options) { + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); using (var map = AndroidOptionsMapper.ToJavaMap(options)) { _bugsee.CallStatic("relaunch", map); @@ -446,17 +448,21 @@ public void DeleteCollectedDataOnDevice() { AndroidManagedReportUploadFence.Invalidate(); _openReport = null; + var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); if (GetLaunched()) { - Stop(InvokeDeleteCollectedDataOnDevice); + Stop(() => InvokeDeleteCollectedDataOnDevice(deleteGeneration)); return; } - InvokeDeleteCollectedDataOnDevice(); + InvokeDeleteCollectedDataOnDevice(deleteGeneration); } - void InvokeDeleteCollectedDataOnDevice() + void InvokeDeleteCollectedDataOnDevice(int deleteGeneration) { + if (!DeleteCollectedDataLaunchGeneration.ShouldRunDelete(deleteGeneration)) + return; + _bugsee.CallStatic( "deleteCollectedDataOnDevice", true, diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 5621d1d..c2b9edb 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -25,6 +25,7 @@ namespace Bugsee.Platform.IOS sealed class IOSBridge : IBugseeNativeBridge { delegate void ManagedReportUploadNativeCallback(int succeeded, ulong uploadToken); + delegate int DeleteCollectedDataShouldRunNativeCallback(int capturedGeneration); ulong _inFlightUploadToken; ulong _managedReportUploadFence; @@ -36,12 +37,15 @@ sealed class IOSBridge : IBugseeNativeBridge IosReport _openReport; IosLiveReport _openReportHandle; readonly NetworkEventLaunchBuffer _networkLaunchBuffer = new NetworkEventLaunchBuffer(); + readonly NetworkEventLaunchBuffer _breadcrumbLaunchBuffer = + new NetworkEventLaunchBuffer(); public bool IsSupported => true; public IOSBridge() { _networkLaunchBuffer.SetSubmitHandler(SubmitNetworkEventToChannel); + _breadcrumbLaunchBuffer.SetSubmitHandler(SubmitBreadcrumbToChannel); } public IFeedback Feedback { get; } = new IosFeedback(); @@ -54,7 +58,9 @@ public void Launch(string appToken, IDictionary options) if (!MainThreadDispatcher.RunSyncLifecycle(() => { EnsureWrapperRegistered(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options))); _launched = true; ExceptionPipeline.Install(this, options); @@ -69,7 +75,9 @@ public void Relaunch(IDictionary options) { if (!MainThreadDispatcher.RunSyncLifecycle(() => { + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options))); ExceptionPipeline.Install(this, options); HostLogForwarder.InstallOnce(this); @@ -92,6 +100,7 @@ public void Stop(Action completion = null) _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); completion?.Invoke(); })) { @@ -132,13 +141,20 @@ public void AddNetworkEvent(INetworkEvent networkEvent) public void NotifyLifecycle(string eventType) { if (eventType == LifecycleEvents.Launched) + { _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Launched); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Launched); + } else if (eventType == LifecycleEvents.Stopped) + { _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + } } public void DeleteCollectedDataOnDevice() { + var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); if (GetLaunched()) { if (!MainThreadDispatcher.RunSyncLifecycle(() => @@ -150,8 +166,9 @@ public void DeleteCollectedDataOnDevice() _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); _launched = false; - _bugsee_delete_collected_data(); + _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); })) { Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); @@ -163,9 +180,13 @@ public void DeleteCollectedDataOnDevice() CancelManagedReportUpload(); _openReport = null; _openReportHandle = null; - _bugsee_delete_collected_data(); + _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); } + [MonoPInvokeCallback(typeof(DeleteCollectedDataShouldRunNativeCallback))] + static int ShouldRunDeleteCollectedDataNative(int capturedGeneration) => + DeleteCollectedDataLaunchGeneration.ShouldRunDelete(capturedGeneration) ? 1 : 0; + void CancelManagedReportUpload() { _reportUploadGeneration++; @@ -238,10 +259,17 @@ void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken) public void AddBreadcrumb(string category, string message, string levelName) { var level = BreadcrumbLevelMap.ParseOrThrow(levelName); - _bugsee_channel_breadcrumb( - category ?? "", - message ?? "", - BreadcrumbLevelMap.ToIos(level)); + _breadcrumbLaunchBuffer.Enqueue(new IosPendingBreadcrumb + { + Category = category ?? "", + Message = message ?? "", + IosLevel = BreadcrumbLevelMap.ToIos(level), + }); + } + + void SubmitBreadcrumbToChannel(IosPendingBreadcrumb breadcrumb) + { + _bugsee_channel_breadcrumb(breadcrumb.Category, breadcrumb.Message, breadcrumb.IosLevel); } public IBugseeExchangeFactory GetExchangeFactory() => IosExchangeFactory.Instance; @@ -471,7 +499,9 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_channel_log(string message, int level, int source); [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); - [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(); + [DllImport("__Internal")] static extern void _bugsee_delete_collected_data( + int capturedGeneration, + DeleteCollectedDataShouldRunNativeCallback shouldRun); [DllImport("__Internal")] static extern void _bugsee_cancel_managed_report_upload(ulong uploadId); [DllImport("__Internal")] static extern void _bugsee_invalidate_managed_report_uploads(); [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( diff --git a/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs new file mode 100644 index 0000000..c529976 --- /dev/null +++ b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs @@ -0,0 +1,19 @@ +using System.Threading; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Launch/Relaunch bumps generation so an in-flight GDPR delete can skip wiping a newer session. + /// + internal static class DeleteCollectedDataLaunchGeneration + { + static int _generation; + + public static int CaptureForPendingDelete() => Volatile.Read(ref _generation); + + public static void BumpForLaunch() => Interlocked.Increment(ref _generation); + + public static bool ShouldRunDelete(int capturedGeneration) => + capturedGeneration == Volatile.Read(ref _generation); + } +} diff --git a/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs.meta b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs.meta new file mode 100644 index 0000000..a29dd25 --- /dev/null +++ b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c4e9a2b1d8f4560a3b2c1d0e9f8a7b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs new file mode 100644 index 0000000..1161414 --- /dev/null +++ b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs @@ -0,0 +1,9 @@ +namespace Bugsee.WrapperPolicy +{ + public struct IosPendingBreadcrumb + { + public string Category; + public string Message; + public int IosLevel; + } +} diff --git a/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs.meta b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs.meta new file mode 100644 index 0000000..bd4ff72 --- /dev/null +++ b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f8a6c1e9b4d4078b5e3d2a1c0f9e8d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs index b2df941..bac87b3 100644 --- a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -1,10 +1,59 @@ using System.Collections.Generic; +using Bugsee.WrapperPolicy; using NUnit.Framework; namespace Bugsee.WrapperPolicy.Tests { public class NetworkEventLaunchBufferTests { + [Test] + public void Delete_generation_launch_bump_invalidates_pending_delete() + { + var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(captured), Is.False); + } + + [Test] + public void Delete_generation_runs_when_generation_matches_current() + { + var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(captured), Is.True); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + var afterLaunch = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(afterLaunch), Is.True); + } + + [Test] + public void Breadcrumb_buffer_flush_preserves_insertion_order_after_launch() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.Enqueue(new IosPendingBreadcrumb { Category = "a", Message = "1", IosLevel = 1 }); + buffer.Enqueue(new IosPendingBreadcrumb { Category = "b", Message = "2", IosLevel = 2 }); + buffer.SetPhase(NetworkLaunchPhase.Launched); + + Assert.That(delivered.Count, Is.EqualTo(2)); + Assert.That(delivered[0].Category, Is.EqualTo("a")); + Assert.That(delivered[1].Category, Is.EqualTo("b")); + } + + [Test] + public void Breadcrumb_buffer_stop_clears_pending_without_flush() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.Enqueue(new IosPendingBreadcrumb { Category = "a", Message = "1", IosLevel = 1 }); + buffer.SetPhase(NetworkLaunchPhase.Stopped); + buffer.SetPhase(NetworkLaunchPhase.Launched); + + Assert.That(delivered, Is.Empty); + } + [Test] public void Flush_preserves_insertion_order_after_launch() { diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index de1d0f5..84bcfe6 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -202,8 +202,10 @@ public void Android_delete_collected_data_stops_then_deletes() { string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); string body = ExtractMethodBody(android, "public void DeleteCollectedDataOnDevice()"); - Assert.That(body, Does.Contain("Stop(InvokeDeleteCollectedDataOnDevice)")); - string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice()"); + Assert.That(body, Does.Contain("CaptureForPendingDelete()")); + Assert.That(body, Does.Contain("InvokeDeleteCollectedDataOnDevice(deleteGeneration)")); + string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice(int deleteGeneration)"); + Assert.That(invoke, Does.Contain("ShouldRunDelete(deleteGeneration)")); Assert.That(invoke, Does.Contain("deleteCollectedDataOnDevice")); Assert.That(invoke, Does.Contain("true")); Assert.That(invoke, Does.Contain("BooleanCallback1Proxy")); @@ -358,7 +360,49 @@ public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); Assert.That(body, Does.Contain("[Bugsee stop:")); Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); + Assert.That(body, Does.Contain("shouldRun(capturedGeneration)")); Assert.That(body, Does.Contain("sharedInstance")); + + string iosDelete = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "public void DeleteCollectedDataOnDevice()"); + Assert.That(iosDelete, Does.Contain("CaptureForPendingDelete()")); + Assert.That(iosDelete, Does.Contain("ShouldRunDeleteCollectedDataNative")); + } + + [Test] + public void Launch_and_relaunch_bump_delete_generation_before_native_call() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + string androidLaunch = ExtractMethodBody(android, "public void Launch(string appToken, IDictionary options)"); + Assert.That(androidLaunch, Does.Contain("BumpForLaunch()")); + Assert.That(androidLaunch, Does.Contain("CallStatic(\"launch\"")); + int launchIdx = androidLaunch.IndexOf("BumpForLaunch()", StringComparison.Ordinal); + int nativeLaunchIdx = androidLaunch.IndexOf("CallStatic(\"launch\"", StringComparison.Ordinal); + Assert.That(launchIdx, Is.LessThan(nativeLaunchIdx)); + + string androidRelaunch = ExtractMethodBody(android, "public void Relaunch(IDictionary options)"); + Assert.That(androidRelaunch, Does.Contain("BumpForLaunch()")); + + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string iosLaunch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); + Assert.That(iosLaunch, Does.Contain("BumpForLaunch()")); + Assert.That(iosLaunch, Does.Contain("_bugsee_launch")); + int iosBump = iosLaunch.IndexOf("BumpForLaunch()", StringComparison.Ordinal); + int iosNative = iosLaunch.IndexOf("_bugsee_launch", StringComparison.Ordinal); + Assert.That(iosBump, Is.LessThan(iosNative)); + } + + [Test] + public void Ios_breadcrumb_buffer_wiring() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string add = ExtractMethodBody(ios, "public void AddBreadcrumb(string category, string message, string levelName)"); + Assert.That(add, Does.Contain("_breadcrumbLaunchBuffer.Enqueue")); + Assert.That(add, Does.Not.Contain("_bugsee_channel_breadcrumb")); + + string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); + Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhase")); } [Test] @@ -386,6 +430,7 @@ public void Ios_network_buffer_wiring() string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); Assert.That(notify, Does.Contain("NetworkLaunchPhase.Launched")); Assert.That(notify, Does.Contain("NetworkLaunchPhase.Stopped")); + Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhase")); string launch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); Assert.That(launch, Does.Contain("SetPhase(NetworkLaunchPhase.BeforeLaunched)")); From 1804e3de97f93c577f8b7609cb7e4a61dab955b3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 19:59:12 +0500 Subject: [PATCH 067/157] Ignore stale lifecycle Stopped after a new iOS launch begins. Co-authored-by: Cursor --- Runtime/Platform/IOS/IOSBridge.cs | 12 ++++-- .../WrapperPolicy/NetworkEventLaunchBuffer.cs | 37 +++++++++++++++++++ .../NetworkEventLaunchBufferTests.cs | 18 +++++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 7 +++- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index c2b9edb..d79b820 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -59,6 +59,8 @@ public void Launch(string appToken, IDictionary options) { EnsureWrapperRegistered(); DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + _networkLaunchBuffer.BeginNewLaunchCycle(); + _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options))); @@ -76,6 +78,8 @@ public void Relaunch(IDictionary options) if (!MainThreadDispatcher.RunSyncLifecycle(() => { DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + _networkLaunchBuffer.BeginNewLaunchCycle(); + _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options))); @@ -142,13 +146,13 @@ public void NotifyLifecycle(string eventType) { if (eventType == LifecycleEvents.Launched) { - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Launched); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Launched); + _networkLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Launched); + _breadcrumbLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Launched); } else if (eventType == LifecycleEvents.Stopped) { - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _networkLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Stopped); } } diff --git a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs index 6b1fdf6..f5a5d79 100644 --- a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs +++ b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs @@ -18,8 +18,14 @@ public sealed class NetworkEventLaunchBuffer readonly object _gate = new object(); readonly List _pending = new List(); NetworkLaunchPhase _phase = NetworkLaunchPhase.BeforeLaunched; + bool _lifecycleStoppedPendingAfterNewLaunch; Action _submit; + /// + /// Call when Launch/Relaunch begins so a late native Stopped does not wipe pre-launch buffers. + /// + public void BeginNewLaunchCycle() => _lifecycleStoppedPendingAfterNewLaunch = true; + public NetworkLaunchPhase Phase { get @@ -39,6 +45,34 @@ public void SetPhase(NetworkLaunchPhase phase) { if (_phase == NetworkLaunchPhase.Launched) return; + _lifecycleStoppedPendingAfterNewLaunch = false; + _phase = NetworkLaunchPhase.Launched; + FlushPendingLocked(); + return; + } + + if (phase == NetworkLaunchPhase.Stopped) + { + _lifecycleStoppedPendingAfterNewLaunch = false; + _phase = NetworkLaunchPhase.Stopped; + _pending.Clear(); + return; + } + + if (phase == NetworkLaunchPhase.BeforeLaunched) + _phase = NetworkLaunchPhase.BeforeLaunched; + } + } + + public void SetPhaseFromLifecycle(NetworkLaunchPhase phase) + { + lock (_gate) + { + if (phase == NetworkLaunchPhase.Launched) + { + if (_phase == NetworkLaunchPhase.Launched) + return; + _lifecycleStoppedPendingAfterNewLaunch = false; _phase = NetworkLaunchPhase.Launched; FlushPendingLocked(); return; @@ -46,6 +80,9 @@ public void SetPhase(NetworkLaunchPhase phase) if (phase == NetworkLaunchPhase.Stopped) { + if (_lifecycleStoppedPendingAfterNewLaunch && _phase == NetworkLaunchPhase.BeforeLaunched) + return; + _lifecycleStoppedPendingAfterNewLaunch = false; _phase = NetworkLaunchPhase.Stopped; _pending.Clear(); return; diff --git a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs index bac87b3..de6cffb 100644 --- a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -40,6 +40,24 @@ public void Breadcrumb_buffer_flush_preserves_insertion_order_after_launch() Assert.That(delivered[1].Category, Is.EqualTo("b")); } + [Test] + public void Lifecycle_stopped_after_new_launch_does_not_clear_pending() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.SetPhase(NetworkLaunchPhase.Launched); + buffer.SetPhase(NetworkLaunchPhase.Stopped); + buffer.BeginNewLaunchCycle(); + buffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + buffer.Enqueue(42); + buffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Stopped); + buffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Launched); + + Assert.That(delivered, Is.EqualTo(new[] { 42 })); + } + [Test] public void Breadcrumb_buffer_stop_clears_pending_without_flush() { diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 84bcfe6..eb29630 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -387,6 +387,7 @@ public void Launch_and_relaunch_bump_delete_generation_before_native_call() string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); string iosLaunch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); Assert.That(iosLaunch, Does.Contain("BumpForLaunch()")); + Assert.That(iosLaunch, Does.Contain("BeginNewLaunchCycle()")); Assert.That(iosLaunch, Does.Contain("_bugsee_launch")); int iosBump = iosLaunch.IndexOf("BumpForLaunch()", StringComparison.Ordinal); int iosNative = iosLaunch.IndexOf("_bugsee_launch", StringComparison.Ordinal); @@ -402,7 +403,8 @@ public void Ios_breadcrumb_buffer_wiring() Assert.That(add, Does.Not.Contain("_bugsee_channel_breadcrumb")); string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); - Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhase")); + Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhaseFromLifecycle")); + Assert.That(notify, Does.Contain("_networkLaunchBuffer.SetPhaseFromLifecycle")); } [Test] @@ -430,7 +432,8 @@ public void Ios_network_buffer_wiring() string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); Assert.That(notify, Does.Contain("NetworkLaunchPhase.Launched")); Assert.That(notify, Does.Contain("NetworkLaunchPhase.Stopped")); - Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhase")); + Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhaseFromLifecycle")); + Assert.That(notify, Does.Contain("_networkLaunchBuffer.SetPhaseFromLifecycle")); string launch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); Assert.That(launch, Does.Contain("SetPhase(NetworkLaunchPhase.BeforeLaunched)")); From e885a71f25a8189d84d723d031eac7ae2cf739e3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 20:19:07 +0500 Subject: [PATCH 068/157] Stamp buffered iOS breadcrumbs at enqueue and apply CreateReport mime types. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 18 ++++++++++++++++++ Plugins/iOS/BugseeUnityCallbacks.mm | 13 ++++++++++--- Runtime/Platform/IOS/IOSBridge.cs | 15 +++++++++++++-- Runtime/WrapperPolicy/IosPendingBreadcrumb.cs | 1 + Tests~/WrapperPolicy/PublicApiSourceTests.cs | 19 +++++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index a4c03cd..68ebe33 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -70,6 +70,20 @@ static void BugseeBridgeSetAttachmentFileNameIfNeeded(id attachment, NSString *d } } +static void BugseeBridgeSetAttachmentMimeTypeIfNeeded(id attachment, NSString *mimeType) +{ + if (!attachment || mimeType.length == 0) { + return; + } + if ([attachment conformsToProtocol:@protocol(BGSAttachmentContract)]) { + ((id)attachment).mimeType = mimeType; + return; + } + if ([attachment respondsToSelector:@selector(setMimeType:)]) { + [attachment performSelector:@selector(setMimeType:) withObject:mimeType]; + } +} + static void BugseeBridgeApplyExtendedReportDict(BugseeExtendedReport *report, NSDictionary *d) { if (![d isKindOfClass:[NSDictionary class]] || !report) { @@ -157,6 +171,10 @@ static void BugseeBridgeApplyExtendedReportDict(BugseeExtendedReport *report, NS } BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:name filename:fileName data:data]; if (attachment) { + id mime = att[@"mimeType"]; + if ([mime isKindOfClass:[NSString class]]) { + BugseeBridgeSetAttachmentMimeTypeIfNeeded(attachment, (NSString *)mime); + } [report setAttachment:attachment]; } } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 6e23cb8..d6bb0ad 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -465,7 +465,7 @@ void _bugsee_channel_network(const char *eventJson, int requiresFiltering) [channel addNetworkEvent:event requiresFiltering:requiresFiltering != 0]; } -void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel) +void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel, double timestampUnixSeconds) { id channel = gChannel; if (!channel) { @@ -486,7 +486,8 @@ void _bugsee_channel_breadcrumb(const char *category, const char *message, int i if (!factory) { return; } - id breadcrumb = [factory createBreadcrumbWithTimestamp:[[NSDate date] timeIntervalSince1970] + NSTimeInterval timestamp = timestampUnixSeconds > 0 ? timestampUnixSeconds : [[NSDate date] timeIntervalSince1970]; + id breadcrumb = [factory createBreadcrumbWithTimestamp:timestamp category:categoryText level:(BugseeLogLevel)iosLevel message:messageText @@ -685,7 +686,13 @@ int _bugsee_test_wrapper_installed(void) void _bugsee_clear_wrapper_channel(void) {} void _bugsee_channel_log(const char *message, int level, int source) { (void)message; (void)level; (void)source; } void _bugsee_channel_network(const char *eventJson, int requiresFiltering) { (void)eventJson; (void)requiresFiltering; } -void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel) { (void)category; (void)message; (void)iosLevel; } +void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel, double timestampUnixSeconds) +{ + (void)category; + (void)message; + (void)iosLevel; + (void)timestampUnixSeconds; +} void _bugsee_ensure_wrapper(const char *version, const char *build) { (void)version; (void)build; } void _bugsee_set_wrapper_context(const char *json) { (void)json; } void _bugsee_set_network_filter_enabled(int enabled) { (void)enabled; } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index d79b820..41706ab 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -260,6 +260,8 @@ void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken) Debug.LogError("[Bugsee] CreateReport failed."); } + static readonly DateTime BreadcrumbUnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + public void AddBreadcrumb(string category, string message, string levelName) { var level = BreadcrumbLevelMap.ParseOrThrow(levelName); @@ -268,12 +270,17 @@ public void AddBreadcrumb(string category, string message, string levelName) Category = category ?? "", Message = message ?? "", IosLevel = BreadcrumbLevelMap.ToIos(level), + TimestampUnixSeconds = (DateTime.UtcNow - BreadcrumbUnixEpoch).TotalSeconds, }); } void SubmitBreadcrumbToChannel(IosPendingBreadcrumb breadcrumb) { - _bugsee_channel_breadcrumb(breadcrumb.Category, breadcrumb.Message, breadcrumb.IosLevel); + _bugsee_channel_breadcrumb( + breadcrumb.Category, + breadcrumb.Message, + breadcrumb.IosLevel, + breadcrumb.TimestampUnixSeconds); } public IBugseeExchangeFactory GetExchangeFactory() => IosExchangeFactory.Instance; @@ -502,7 +509,11 @@ static string JsonString(string s) [DllImport("__Internal")] static extern void _bugsee_log(string message, int level); [DllImport("__Internal")] static extern void _bugsee_channel_log(string message, int level, int source); [DllImport("__Internal")] static extern void _bugsee_channel_network(string eventJson, int requiresFiltering); - [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb(string category, string message, int iosLevel); + [DllImport("__Internal")] static extern void _bugsee_channel_breadcrumb( + string category, + string message, + int iosLevel, + double timestampUnixSeconds); [DllImport("__Internal")] static extern void _bugsee_delete_collected_data( int capturedGeneration, DeleteCollectedDataShouldRunNativeCallback shouldRun); diff --git a/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs index 1161414..0357006 100644 --- a/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs +++ b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs @@ -5,5 +5,6 @@ public struct IosPendingBreadcrumb public string Category; public string Message; public int IosLevel; + public double TimestampUnixSeconds; } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index eb29630..a09318e 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -296,6 +296,24 @@ public void Ios_apply_report_dict_overlays_attributes_without_default_clear() Assert.That(body, Does.Contain("clearAllAttachments")); } + [Test] + public void Ios_apply_report_dict_applies_attachment_mime_type() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string body = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyExtendedReportDict"); + Assert.That(body, Does.Contain("att[@\"mimeType\"]")); + Assert.That(body, Does.Contain("BugseeBridgeSetAttachmentMimeTypeIfNeeded")); + } + + [Test] + public void Ios_channel_breadcrumb_uses_enqueue_timestamp() + { + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + string body = ExtractNativeFunctionBody(callbacks, "_bugsee_channel_breadcrumb"); + Assert.That(body, Does.Contain("timestampUnixSeconds")); + Assert.That(body, Does.Contain("createBreadcrumbWithTimestamp:timestamp")); + } + [Test] public void Ios_to_result_json_omits_unset_severity_zero() { @@ -400,6 +418,7 @@ public void Ios_breadcrumb_buffer_wiring() string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); string add = ExtractMethodBody(ios, "public void AddBreadcrumb(string category, string message, string levelName)"); Assert.That(add, Does.Contain("_breadcrumbLaunchBuffer.Enqueue")); + Assert.That(add, Does.Contain("TimestampUnixSeconds")); Assert.That(add, Does.Not.Contain("_bugsee_channel_breadcrumb")); string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); From b2a1aa5d68c37c0ffad7f0a79c647292ea507fbf Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 20:29:07 +0500 Subject: [PATCH 069/157] Ignore late lifecycle Stopped until managed Stop clears launch cycle. Co-authored-by: Cursor --- .../WrapperPolicy/NetworkEventLaunchBuffer.cs | 5 +---- .../NetworkEventLaunchBufferTests.cs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs index f5a5d79..d16c5c8 100644 --- a/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs +++ b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs @@ -45,7 +45,6 @@ public void SetPhase(NetworkLaunchPhase phase) { if (_phase == NetworkLaunchPhase.Launched) return; - _lifecycleStoppedPendingAfterNewLaunch = false; _phase = NetworkLaunchPhase.Launched; FlushPendingLocked(); return; @@ -72,7 +71,6 @@ public void SetPhaseFromLifecycle(NetworkLaunchPhase phase) { if (_phase == NetworkLaunchPhase.Launched) return; - _lifecycleStoppedPendingAfterNewLaunch = false; _phase = NetworkLaunchPhase.Launched; FlushPendingLocked(); return; @@ -80,9 +78,8 @@ public void SetPhaseFromLifecycle(NetworkLaunchPhase phase) if (phase == NetworkLaunchPhase.Stopped) { - if (_lifecycleStoppedPendingAfterNewLaunch && _phase == NetworkLaunchPhase.BeforeLaunched) + if (_lifecycleStoppedPendingAfterNewLaunch) return; - _lifecycleStoppedPendingAfterNewLaunch = false; _phase = NetworkLaunchPhase.Stopped; _pending.Clear(); return; diff --git a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs index de6cffb..fccb2c9 100644 --- a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -58,6 +58,23 @@ public void Lifecycle_stopped_after_new_launch_does_not_clear_pending() Assert.That(delivered, Is.EqualTo(new[] { 42 })); } + [Test] + public void Lifecycle_stopped_after_launched_does_not_drop_later_events() + { + var delivered = new List(); + var buffer = new NetworkEventLaunchBuffer(); + buffer.SetSubmitHandler(v => delivered.Add(v)); + + buffer.BeginNewLaunchCycle(); + buffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + buffer.Enqueue(1); + buffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Launched); + buffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Stopped); + buffer.Enqueue(2); + + Assert.That(delivered, Is.EqualTo(new[] { 1, 2 })); + } + [Test] public void Breadcrumb_buffer_stop_clears_pending_without_flush() { From b5641b2305b3e1e63744b49c2212ac0000b83d5f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 20:47:22 +0500 Subject: [PATCH 070/157] Clear iOS launch buffers on delete when not launched. Co-authored-by: Cursor --- Runtime/Platform/IOS/IOSBridge.cs | 2 ++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 41706ab..c1ffb6b 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -184,6 +184,8 @@ public void DeleteCollectedDataOnDevice() CancelManagedReportUpload(); _openReport = null; _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index a09318e..5e6d2e0 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -510,6 +510,8 @@ public void Ios_delete_clears_open_report_slot_when_stopped() "public void DeleteCollectedDataOnDevice()"); Assert.That(body, Does.Contain("_openReport = null")); Assert.That(body, Does.Contain("_openReportHandle = null")); + Assert.That(body, Does.Contain("_networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped)")); + Assert.That(body, Does.Contain("_breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped)")); } [Test] From 802063a4d1b984e7da80d92c7e9b914c456406c7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 21:21:10 +0500 Subject: [PATCH 071/157] Load CreateReport file attachments from path and release snapshot files after upload. Co-authored-by: Cursor --- Plugins/iOS/BugseeUnityBridge.mm | 24 ++++++++-- Runtime/Platform/IOS/IOSBridge.cs | 23 ++++++++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 2 + Tests~/WrapperPolicy/ReportPathSourceTests.cs | 48 +++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 68ebe33..e49a01b 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -157,6 +157,23 @@ static void BugseeBridgeApplyExtendedReportDict(BugseeExtendedReport *report, NS if (fileName.length == 0) { fileName = name; } + id mime = att[@"mimeType"]; + NSString *mimeType = [mime isKindOfClass:[NSString class]] ? (NSString *)mime : nil; + + id pathVal = att[@"path"]; + if ([pathVal isKindOfClass:[NSString class]] && [(NSString *)pathVal length] > 0) { + NSData *fileData = [NSData dataWithContentsOfFile:(NSString *)pathVal]; + if (!fileData) { + continue; + } + BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:name filename:fileName data:fileData]; + if (attachment) { + BugseeBridgeSetAttachmentMimeTypeIfNeeded(attachment, mimeType); + [report setAttachment:attachment]; + } + continue; + } + NSData *data = nil; id b64 = att[@"dataBase64"]; if ([b64 isKindOfClass:[NSString class]] && [(NSString *)b64 length] > 0) { @@ -167,14 +184,11 @@ static void BugseeBridgeApplyExtendedReportDict(BugseeExtendedReport *report, NS data = [(NSString *)text dataUsingEncoding:NSUTF8StringEncoding]; } if (!data) { - data = [NSData data]; + continue; } BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:name filename:fileName data:data]; if (attachment) { - id mime = att[@"mimeType"]; - if ([mime isKindOfClass:[NSString class]]) { - BugseeBridgeSetAttachmentMimeTypeIfNeeded(attachment, (NSString *)mime); - } + BugseeBridgeSetAttachmentMimeTypeIfNeeded(attachment, mimeType); [report setAttachment:attachment]; } } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 230c792..6e6c27c 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -36,6 +36,7 @@ sealed class IOSBridge : IBugseeNativeBridge IosAppearance _appearance; IosReport _openReport; IosLiveReport _openReportHandle; + readonly Dictionary _uploadSnapshotReports = new Dictionary(); readonly NetworkEventLaunchBuffer _networkLaunchBuffer = new NetworkEventLaunchBuffer(); readonly NetworkEventLaunchBuffer _breadcrumbLaunchBuffer = new NetworkEventLaunchBuffer(); @@ -198,9 +199,25 @@ void CancelManagedReportUpload() _reportUploadGeneration++; _managedReportUploadFence++; _inFlightUploadToken = 0; + ReleaseAllUploadSnapshotReports(); _bugsee_invalidate_managed_report_uploads(); } + void ReleaseAllUploadSnapshotReports() + { + foreach (var kv in _uploadSnapshotReports) + kv.Value?.ReleaseSnapshotFiles(); + _uploadSnapshotReports.Clear(); + } + + void ReleaseUploadSnapshotReport(ulong uploadToken) + { + if (!_uploadSnapshotReports.TryGetValue(uploadToken, out var report)) + return; + _uploadSnapshotReports.Remove(uploadToken); + report?.ReleaseSnapshotFiles(); + } + public IReport CreateReport() { if (_openReport != null) @@ -217,6 +234,7 @@ public void DiscardReport(IReport report) if (!ReferenceEquals(report, _openReportHandle)) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); _reportUploadGeneration++; + _openReport?.ReleaseSnapshotFiles(); _openReport = null; _openReportHandle = null; } @@ -232,9 +250,11 @@ public void UploadReport(IReport report) Debug.LogWarning( "[Bugsee] Email on CreateReport is not supported on iOS managed upload; use session identity APIs."); } - var json = _openReport.ToResultJson(); + var snapshotReport = _openReport; + var json = snapshotReport.ToResultJson(); var uploadToken = (ulong)++_reportUploadGeneration; var uploadFence = _managedReportUploadFence; + _uploadSnapshotReports[uploadToken] = snapshotReport; _openReport = null; _openReportHandle = null; _uploadCompletionBridge = this; @@ -255,6 +275,7 @@ static void OnManagedReportCreateCompletion(int succeeded, ulong uploadToken) void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken) { + ReleaseUploadSnapshotReport(uploadToken); if (uploadToken != _inFlightUploadToken) return; _inFlightUploadToken = 0; diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 5653447..6bdaa0e 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -130,6 +130,8 @@ public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() string apply = ExtractNativeFunctionBody(bridge, "BugseeBridgeApplyExtendedReportDict"); Assert.That(apply, Does.Contain("[report clearAllAttachments]")); + Assert.That(apply, Does.Contain("att[@\"path\"]")); + Assert.That(apply, Does.Contain("dataWithContentsOfFile:")); Assert.That(apply, Does.Contain("[BugseeAttachment attachmentWithName:name filename:fileName data:data]")); Assert.That(apply, Does.Contain("[report setAttachment:attachment]")); Assert.That(apply, Does.Not.Contain("createAndAddAttachmentWithName")); diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index 9dfcb18..9ed507c 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -42,6 +42,54 @@ static string ExtractMethodBody(string source, string signature) return null; } + static string ExtractNativeFunctionBody(string source, string functionName) + { + var marker = "static void " + functionName; + int start = source.IndexOf(marker, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing function: " + functionName); + int brace = source.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) + return source.Substring(start, i - start + 1); + } + } + + Assert.Fail("Unclosed function body: " + functionName); + return null; + } + + [Test] + public void Ios_extended_apply_uses_path_and_bytes_attachments() + { + string mm = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string apply = ExtractNativeFunctionBody(mm, "BugseeBridgeApplyExtendedReportDict"); + Assert.That(apply, Does.Contain("att[@\"path\"]")); + Assert.That(apply, Does.Contain("dataWithContentsOfFile:")); + Assert.That(apply, Does.Contain("dataBase64")); + Assert.That(apply, Does.Contain("[BugseeAttachment attachmentWithName:name filename:fileName data:data]")); + } + + [Test] + public void Ios_create_report_upload_releases_snapshots_after_native_apply() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string discard = ExtractMethodBody(ios, "public void DiscardReport(IReport report)"); + Assert.That(discard, Does.Contain("ReleaseSnapshotFiles()")); + + string upload = ExtractMethodBody(ios, "public void UploadReport(IReport report)"); + Assert.That(upload, Does.Contain("_uploadSnapshotReports[uploadToken]")); + + string completion = ExtractMethodBody(ios, "void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken)"); + Assert.That(completion, Does.Contain("ReleaseUploadSnapshotReport(uploadToken)")); + } + [Test] public void Ios_apply_report_dict_uses_path_and_bytes_attachments() { From 17aae36f04a432de8c870a419add685849423a66 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 21:59:37 +0500 Subject: [PATCH 072/157] Release abandoned report snapshots on Stop/Delete and snapshot Android file attachments at add. Co-authored-by: Cursor --- Runtime/Platform/Android/AndroidBridge.cs | 3 + Runtime/Platform/Android/AndroidReport.cs | 69 ++++++++++++++++++- Runtime/Platform/IOS/IOSBridge.cs | 27 ++++++-- Tests~/WrapperPolicy/ReportPathSourceTests.cs | 18 +++++ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 007d6c8..d0339cb 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -95,6 +95,7 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { AndroidManagedReportUploadFence.Invalidate(); + _openReport?.ReleaseSnapshotFiles(); _openReport = null; HostLogForwarder.Uninstall(); ExceptionPipeline.Uninstall(); @@ -462,6 +463,7 @@ public void NotifyLifecycle(string eventType) { } public void DeleteCollectedDataOnDevice() { AndroidManagedReportUploadFence.Invalidate(); + _openReport?.ReleaseSnapshotFiles(); _openReport = null; var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); if (GetLaunched()) @@ -502,6 +504,7 @@ public void DiscardReport(IReport report) throw new ArgumentNullException(nameof(report)); if (!ReferenceEquals(report, _openReport)) throw new ArgumentException("Report was not created by CreateReport.", nameof(report)); + _openReport?.ReleaseSnapshotFiles(); _openReport = null; } diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index 349531d..d1fb1a9 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -319,18 +319,50 @@ public void SetLabels(IEnumerable labels) public IAttachment AddAttachmentFile(string path, string name, string mimeType) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) + return null; + string snapshotPath; + try + { + snapshotPath = SnapshotAttachmentFile(path); + } + catch (System.IO.IOException) + { return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + var att = new AndroidManagedAttachment(string.IsNullOrEmpty(name) ? "attachment" : name, MarkAttachmentsDirty); var baseName = System.IO.Path.GetFileName(path); att.Filename = string.IsNullOrEmpty(baseName) ? att.Name : baseName; att.MimeType = string.IsNullOrEmpty(mimeType) ? "application/octet-stream" : mimeType; - att.SetFilePath(path); + att.SetSnapshotPath(snapshotPath); _attachments.Add(att); _attachmentsDirty = true; return att; } + static string SnapshotAttachmentFile(string sourcePath) + { + var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "bugsee-unity-report-attachments"); + System.IO.Directory.CreateDirectory(dir); + var ext = System.IO.Path.GetExtension(sourcePath); + if (string.IsNullOrEmpty(ext)) + ext = ".bin"; + var dest = System.IO.Path.Combine(dir, Guid.NewGuid().ToString("N") + ext); + System.IO.File.Copy(sourcePath, dest, true); + return dest; + } + + internal void ReleaseSnapshotFiles() + { + for (var i = 0; i < _attachments.Count; i++) + _attachments[i].DeleteSnapshotIfOwned(); + } + public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) { if (data == null || data.Length == 0) @@ -345,6 +377,7 @@ public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) public void ClearAttachments() { + ReleaseSnapshotFiles(); _attachments.Clear(); _attachmentsDirty = true; } @@ -407,6 +440,7 @@ sealed class AndroidManagedAttachment : IAttachment byte[] _bytes; string _text; string _path; + bool _ownsSnapshotFile; public AndroidManagedAttachment(string name, Action markDirty) { @@ -438,12 +472,43 @@ public void SetData(string text) public void SetFilePath(string path) { + DeleteSnapshotIfOwned(); _path = path; _bytes = null; _text = null; + _ownsSnapshotFile = false; _markDirty?.Invoke(); } + internal void SetSnapshotPath(string snapshotPath) + { + DeleteSnapshotIfOwned(); + _path = snapshotPath; + _bytes = null; + _text = null; + _ownsSnapshotFile = true; + _markDirty?.Invoke(); + } + + internal void DeleteSnapshotIfOwned() + { + if (!_ownsSnapshotFile || string.IsNullOrEmpty(_path)) + return; + try + { + if (System.IO.File.Exists(_path)) + System.IO.File.Delete(_path); + } + catch (System.IO.IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + _ownsSnapshotFile = false; + } + internal void ApplyTo(AndroidReport report) { IAttachment attachment = null; diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 6e6c27c..499778f 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -36,6 +36,7 @@ sealed class IOSBridge : IBugseeNativeBridge IosAppearance _appearance; IosReport _openReport; IosLiveReport _openReportHandle; + readonly object _uploadSnapshotReportsGate = new object(); readonly Dictionary _uploadSnapshotReports = new Dictionary(); readonly NetworkEventLaunchBuffer _networkLaunchBuffer = new NetworkEventLaunchBuffer(); readonly NetworkEventLaunchBuffer _breadcrumbLaunchBuffer = @@ -102,6 +103,7 @@ public void Stop(Action completion = null) _bugsee_stop(); _launched = false; CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); @@ -168,6 +170,7 @@ public void DeleteCollectedDataOnDevice() ExceptionPipeline.Uninstall(); _bugsee_clear_wrapper_channel(); CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); @@ -183,6 +186,7 @@ public void DeleteCollectedDataOnDevice() } CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); @@ -205,16 +209,24 @@ void CancelManagedReportUpload() void ReleaseAllUploadSnapshotReports() { - foreach (var kv in _uploadSnapshotReports) - kv.Value?.ReleaseSnapshotFiles(); - _uploadSnapshotReports.Clear(); + lock (_uploadSnapshotReportsGate) + { + foreach (var kv in _uploadSnapshotReports) + kv.Value?.ReleaseSnapshotFiles(); + _uploadSnapshotReports.Clear(); + } } void ReleaseUploadSnapshotReport(ulong uploadToken) { - if (!_uploadSnapshotReports.TryGetValue(uploadToken, out var report)) - return; - _uploadSnapshotReports.Remove(uploadToken); + IosReport report; + lock (_uploadSnapshotReportsGate) + { + if (!_uploadSnapshotReports.TryGetValue(uploadToken, out report)) + return; + _uploadSnapshotReports.Remove(uploadToken); + } + report?.ReleaseSnapshotFiles(); } @@ -254,7 +266,8 @@ public void UploadReport(IReport report) var json = snapshotReport.ToResultJson(); var uploadToken = (ulong)++_reportUploadGeneration; var uploadFence = _managedReportUploadFence; - _uploadSnapshotReports[uploadToken] = snapshotReport; + lock (_uploadSnapshotReportsGate) + _uploadSnapshotReports[uploadToken] = snapshotReport; _openReport = null; _openReportHandle = null; _uploadCompletionBridge = this; diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index 9ed507c..ba53101 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -88,6 +88,11 @@ public void Ios_create_report_upload_releases_snapshots_after_native_apply() string completion = ExtractMethodBody(ios, "void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken)"); Assert.That(completion, Does.Contain("ReleaseUploadSnapshotReport(uploadToken)")); + + string stop = ExtractMethodBody(ios, "public void Stop(Action completion = null)"); + Assert.That(stop, Does.Contain("_openReport?.ReleaseSnapshotFiles()")); + string delete = ExtractMethodBody(ios, "public void DeleteCollectedDataOnDevice()"); + Assert.That(delete, Does.Contain("_openReport?.ReleaseSnapshotFiles()")); } [Test] @@ -266,6 +271,19 @@ public void Ios_report_snapshots_files_and_base64_bytes() Assert.That(addBytes, Does.Not.Contain("SetData(data)")); } + [Test] + public void Android_managed_report_snapshots_files_at_add() + { + string report = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidReport.cs")); + int managedStart = report.IndexOf("sealed class AndroidManagedReport", StringComparison.Ordinal); + Assert.That(managedStart, Is.GreaterThanOrEqualTo(0)); + string managed = report.Substring(managedStart); + string addFile = ExtractMethodBody(managed, "public IAttachment AddAttachmentFile(string path, string name, string mimeType)"); + Assert.That(addFile, Does.Contain("SnapshotAttachmentFile")); + Assert.That(addFile, Does.Contain("SetSnapshotPath")); + Assert.That(addFile, Does.Contain("File.Exists(path)")); + } + [Test] public void IReport_contract_exposes_path_and_bytes_attachments() { From b8b2fd3be05d21c4f18416437ad063a03b40545c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 22:07:48 +0500 Subject: [PATCH 073/157] Release Android CreateReport file snapshots after native apply in onCreated. Co-authored-by: Cursor --- .../Android/Proxies/CallbackProxies.cs | 22 +++++++++++-------- Tests~/WrapperPolicy/ReportPathSourceTests.cs | 12 ++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index 2427959..63eeda9 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -202,17 +202,17 @@ public ReportCreationListenerProxy( public void onCreated(AndroidJavaObject report) { - if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) - return; - - if (report == null) - { - Debug.LogError("[Bugsee] CreateReport failed."); - return; - } - try { + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; + + if (report == null) + { + Debug.LogError("[Bugsee] CreateReport failed."); + return; + } + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) return; _snapshot.ApplyTo(report); @@ -224,6 +224,10 @@ public void onCreated(AndroidJavaObject report) { Debug.LogException(ex); } + finally + { + _snapshot?.ReleaseSnapshotFiles(); + } } } } diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index ba53101..7c9e23b 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -271,6 +271,18 @@ public void Ios_report_snapshots_files_and_base64_bytes() Assert.That(addBytes, Does.Not.Contain("SetData(data)")); } + [Test] + public void Android_upload_releases_snapshots_after_onCreated() + { + string proxies = File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs")); + string onCreated = ExtractMethodBody(proxies, "public void onCreated(AndroidJavaObject report)"); + Assert.That(onCreated, Does.Contain("ApplyTo(report)")); + Assert.That(onCreated, Does.Contain("ReleaseSnapshotFiles()")); + int apply = onCreated.IndexOf("ApplyTo(report)", StringComparison.Ordinal); + int release = onCreated.IndexOf("ReleaseSnapshotFiles()", StringComparison.Ordinal); + Assert.That(release, Is.GreaterThan(apply)); + } + [Test] public void Android_managed_report_snapshots_files_at_add() { From 30caf3d1ba6fed775490eca6cf1e415168cc7342 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 22:38:38 +0500 Subject: [PATCH 074/157] Serve secure rectangles from the versioned pull buffer. Co-authored-by: Cursor --- Plugins/Android/UnityWrapperProvider.java | 10 ++ Plugins/Android/bugsee-unity-wrapper.proguard | 1 + Plugins/iOS/BugseeUnityBridge.mm | 27 ++-- Plugins/iOS/BugseeUnityCallbacks.mm | 42 ++++++ Runtime/Bugsee.cs | 43 +++++- Runtime/Platform/Android/AndroidBridge.cs | 17 +-- .../Android/Proxies/BugseeWrapperProxy.cs | 115 --------------- Runtime/Platform/Editor/EditorBridge.cs | 6 +- Runtime/Platform/IBugseeNativeBridge.cs | 8 +- Runtime/Platform/IOS/IOSBridge.cs | 17 ++- .../WrapperPolicy/SecureRectManualOwners.cs | 40 ++++++ .../SecureRectManualOwners.cs.meta} | 2 +- Runtime/WrapperPolicy/SecureRectRegistry.cs | 20 +++ .../SecureRectManualOwnerTests.cs | 73 ++++++++++ Tests~/WrapperPolicy/SecureRectSourceTests.cs | 135 ++++++++++++++++++ 15 files changed, 400 insertions(+), 156 deletions(-) delete mode 100644 Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs create mode 100644 Runtime/WrapperPolicy/SecureRectManualOwners.cs rename Runtime/{Platform/Android/Proxies/BugseeWrapperProxy.cs.meta => WrapperPolicy/SecureRectManualOwners.cs.meta} (83%) create mode 100644 Tests~/WrapperPolicy/SecureRectManualOwnerTests.cs create mode 100644 Tests~/WrapperPolicy/SecureRectSourceTests.cs diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index a8ea8c9..b323dd6 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -8,6 +8,7 @@ import com.bugsee.library.contracts.reporting.Report; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; // initOrder 200 public final class UnityWrapperProvider extends BugseeExtensionInitProviderBase { @@ -28,6 +29,8 @@ final class UnityWrapper implements BugseeWrapper { private String productName = "unknown"; private String wrapperVersion = "unknown"; private static volatile BugseeWrapperChannel channel; + private static final ConcurrentHashMap secureBuffers = + new ConcurrentHashMap(); static void install() { if (installed) return; @@ -120,8 +123,15 @@ public static void channelAddNetwork(com.bugsee.library.contracts.exchange.Netwo current.addNetworkEvent(event, true); } + public static void setSecureBuffer(int display, int[] packed) { + if (packed == null || packed.length < 2) return; + secureBuffers.put(display, packed.clone()); + } + @Override public int[] getSecureRectangles(int display) { + int[] cached = secureBuffers.get(display); + if (cached != null) return cached.clone(); return new int[] { 1, 0 }; } diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard index 2ebfd87..9234aee 100644 --- a/Plugins/Android/bugsee-unity-wrapper.proguard +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -4,5 +4,6 @@ static void channelLog(java.lang.String, int, int); static void channelAddNetwork(com.bugsee.library.contracts.exchange.NetworkEvent); static void clearWrapperChannel(); + static void setSecureBuffer(int, int[]); } -keep class com.bugsee.unity.UnityWrapperProvider { *; } diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index e49a01b..13bdb11 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -403,21 +403,25 @@ void _bugsee_clear_email(void) void _bugsee_add_secure_rect(float x, float y, float w, float h) { - CGFloat scale = [UIScreen mainScreen].scale; - CGRect rect = CGRectMake(x / scale, y / scale, w / scale, h / scale); - [Bugsee addSecureRectangle:rect]; + (void)x; + (void)y; + (void)w; + (void)h; } void _bugsee_remove_secure_rect(float x, float y, float w, float h) { - CGFloat scale = [UIScreen mainScreen].scale; - CGRect rect = CGRectMake(x / scale, y / scale, w / scale, h / scale); - [Bugsee removeSecureRectangle:rect]; + (void)x; + (void)y; + (void)w; + (void)h; } -void _bugsee_remove_all_secure_rects(void) +void _bugsee_remove_all_secure_rects(void) {} + +float _bugsee_screen_scale(void) { - [Bugsee removeAllSecureRectangles]; + return (float)[UIScreen mainScreen].scale; } void _bugsee_capture_view_hierarchy(void) @@ -624,6 +628,13 @@ void _bugsee_clear_email(void) {} void _bugsee_add_secure_rect(float x, float y, float w, float h) { (void)x; (void)y; (void)w; (void)h; } void _bugsee_remove_secure_rect(float x, float y, float w, float h) { (void)x; (void)y; (void)w; (void)h; } void _bugsee_remove_all_secure_rects(void) {} +float _bugsee_screen_scale(void) { return 1.f; } +void _bugsee_set_secure_buffer(int display, int *packed, int packedLength) +{ + (void)display; + (void)packed; + (void)packedLength; +} void _bugsee_capture_view_hierarchy(void) {} void _bugsee_feedback_show(void) {} void _bugsee_feedback_set_greeting(const char *message) { (void)message; } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index bfadb1e..894c850 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -46,6 +46,18 @@ @class BugseeUnityWrapper; static BugseeUnityWrapper *gWrapper; static id gChannel; +static NSMutableDictionary *gSecureRectBuffers; +static NSData *gDefaultSecureRectBuffer; + +static void BugseeUnityEnsureSecureRectBuffers(void) +{ + static dispatch_once_t once; + dispatch_once(&once, ^{ + gSecureRectBuffers = [NSMutableDictionary dictionary]; + int32_t baseline[] = { 1, 0 }; + gDefaultSecureRectBuffer = [NSData dataWithBytes:baseline length:sizeof(baseline)]; + }); +} static void BugseeUnityEnsureState(void) { @@ -56,6 +68,19 @@ static void BugseeUnityEnsureState(void) }); } +static NSData *BugseeUnitySecureBufferForDisplay(int display) +{ + BugseeUnityEnsureState(); + BugseeUnityEnsureSecureRectBuffers(); + @synchronized (gLock) { + NSData *data = gSecureRectBuffers[@(display)] ?: gDefaultSecureRectBuffer; + if (!data || data.length == 0) { + return gDefaultSecureRectBuffer; + } + return [NSData dataWithBytes:data.bytes length:data.length]; + } +} + static char *BugseeUnityCopyUTF8(NSString *string) { if (!string) return NULL; @@ -386,6 +411,11 @@ - (void)onAfterReportCreated:(id)report [self forwardReport:report phase:1 isTerminating:isTerminating completion:completion]; } +- (NSData *)secureRectanglesForDisplay:(NSInteger)display +{ + return BugseeUnitySecureBufferForDisplay((int)display); +} + - (void)forwardReport:(id)report phase:(int)phase isTerminating:(BOOL)isTerminating @@ -452,6 +482,18 @@ void _bugsee_clear_wrapper_channel(void) gChannel = nil; } +void _bugsee_set_secure_buffer(int display, int *packed, int packedLength) +{ + if (!packed || packedLength < 2) return; + BugseeUnityEnsureState(); + BugseeUnityEnsureSecureRectBuffers(); + NSData *data = [NSData dataWithBytes:packed length:(NSUInteger)packedLength * sizeof(int32_t)]; + if (!data) return; + @synchronized (gLock) { + gSecureRectBuffers[@(display)] = data; + } +} + void _bugsee_channel_log(const char *message, int level, int source) { id channel = gChannel; diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 85a380d..7a3d88d 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -9,6 +9,7 @@ using Bugsee.Internal; using Bugsee.Platform; using Bugsee.Platform.EditorStub; +using Bugsee.WrapperPolicy; using UnityEngine; #if UNITY_ANDROID && !UNITY_EDITOR using Bugsee.Platform.Android; @@ -25,6 +26,8 @@ public static class Bugsee { static readonly IBugseeNativeBridge Bridge = CreateBridge(); static ILifecycleEventListener _appLifecycleListener; + const int DefaultSecureRectDisplayId = 0; + static readonly SecureRectManualOwners _manualSecureRectOwners = new SecureRectManualOwners(); /// Raised for every native lifecycle event (all 7.x strings). public static event Action LifecycleEvent; @@ -148,13 +151,43 @@ public static void SetUserIdentifier(string userIdentifier) => public static void ClearAllAttributes() => Bridge.ClearAllAttributes(); - public static void AddSecureRectangle(RectInt pixelRect) => - Bridge.AddSecureRectangle(pixelRect.xMin, pixelRect.yMin, pixelRect.xMax, pixelRect.yMax); + public static void AddSecureRectangle(RectInt pixelRect) + { + _manualSecureRectOwners.Add( + pixelRect.xMin, + pixelRect.yMin, + pixelRect.xMax, + pixelRect.yMax, + SecureRectRegistry.Instance, + DefaultSecureRectDisplayId); + PushSecureBufferForDisplay(DefaultSecureRectDisplayId); + } - public static void RemoveSecureRectangle(RectInt pixelRect) => - Bridge.RemoveSecureRectangle(pixelRect.xMin, pixelRect.yMin, pixelRect.xMax, pixelRect.yMax); + public static void RemoveSecureRectangle(RectInt pixelRect) + { + if (!_manualSecureRectOwners.Remove( + pixelRect.xMin, + pixelRect.yMin, + pixelRect.xMax, + pixelRect.yMax, + SecureRectRegistry.Instance)) + return; + + PushSecureBufferForDisplay(DefaultSecureRectDisplayId); + } - public static void RemoveAllSecureRectangles() => Bridge.RemoveAllSecureRectangles(); + public static void RemoveAllSecureRectangles() + { + _manualSecureRectOwners.RemoveAll(SecureRectRegistry.Instance); + PushSecureBufferForDisplay(DefaultSecureRectDisplayId); + } + + static void PushSecureBufferForDisplay(int displayId) + { + float pixelsPerNativeUnit = Bridge.GetSecureRectSnapshotScale(); + int[] packed = SecureRectRegistry.Instance.Snapshot(displayId, pixelsPerNativeUnit); + Bridge.SetSecureBuffer(displayId, packed); + } public static void CaptureViewHierarchy() => Bridge.CaptureViewHierarchy(); diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index d0339cb..615b094 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -355,23 +355,16 @@ public object GetAttribute(string key) public void ClearAllAttributes() => _bugsee.CallStatic("clearAllAttributes"); - public void AddSecureRectangle(int left, int top, int right, int bottom) + public void SetSecureBuffer(int display, int[] packed) { - using (var rect = new AndroidJavaObject("android.graphics.Rect", left, top, right, bottom)) - { - _bugsee.CallStatic("addSecureRectangle", rect); - } - } - - public void RemoveSecureRectangle(int left, int top, int right, int bottom) - { - using (var rect = new AndroidJavaObject("android.graphics.Rect", left, top, right, bottom)) + if (packed == null) return; + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) { - _bugsee.CallStatic("removeSecureRectangle", rect); + wrapper.CallStatic("setSecureBuffer", display, packed); } } - public void RemoveAllSecureRectangles() => _bugsee.CallStatic("removeAllSecureRectangles"); + public float GetSecureRectSnapshotScale() => 1f; public void CaptureViewHierarchy() => _bugsee.CallStatic("captureViewHierarchy"); diff --git a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs b/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs deleted file mode 100644 index 9a6cde1..0000000 --- a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs +++ /dev/null @@ -1,115 +0,0 @@ -#if UNITY_ANDROID && !UNITY_EDITOR -using System.Collections.Concurrent; -using System.Collections.Generic; -using Bugsee; -using UnityEngine; - -namespace Bugsee.Platform.Android -{ - /// - /// Unity implementation of com.bugsee.library.contracts.internal.BugseeWrapper. - /// Registered via Bugsee.setWrapper — not an application-facing API. - /// - sealed class BugseeWrapperProxy : AndroidJavaProxy - { - // Packed secure-rect buffer per display: [version, count, l,t,r,b, ...] - readonly ConcurrentDictionary _secureRectsByDisplay = new ConcurrentDictionary(); - - public BugseeWrapperProxy() - : base("com.bugsee.library.contracts.internal.BugseeWrapper") - { - } - - public string getWrapperVersion() => BugseePackageVersion.Version; - - public string getWrapperBuild() => BugseePackageVersion.Build; - - public string getWrapperType() => "unity"; - - // Cached on first main-thread access — getContext() is invoked off the main thread. - static string _unityVersion = ""; - static string _platform = ""; - static string _productName = ""; - static bool _contextCached; - - internal static void CacheHostContext() - { - _unityVersion = Application.unityVersion ?? ""; - _platform = Application.platform.ToString(); - _productName = Application.productName ?? ""; - _contextCached = true; - } - - public AndroidJavaObject getContext() - { - if (!_contextCached) - { - // Best-effort; may be empty if never warmed from main thread. - } - - var map = new AndroidJavaObject("java.util.HashMap"); - map.Call("put", "unity_version", _unityVersion); - map.Call("put", "unity_platform", _platform); - map.Call("put", "product_name", _productName); - return map; - } - - public void requestData(string dataType, AndroidJavaObject callback) - { - // No extra Unity data sources yet — always complete with null. - callback?.Call("onResult", (string)null); - } - - // Android SDK may pass Object as Map/null or as a JSON String; Unity JNI - // resolves the exact overload, so both signatures are required. - public void onLifecycleEvent(string eventType, AndroidJavaObject data) - { - // Lifecycle is forwarded via LifecycleListenerProxy → Bugsee.HandleNativeLifecycle. - // Keep this hook for future wrapper-only side effects (do not double-dispatch). - } - - public void onLifecycleEvent(string eventType, string data) - { - // See AndroidJavaObject overload — do not double-dispatch. - } - - public int[] getSecureRectangles(int display) - { - if (_secureRectsByDisplay.TryGetValue(display, out int[] cached)) - return (int[])cached.Clone(); - - return new[] { 1, 0 }; - } - - // ReportHandler defaults — no-op continue. App handler is separate via setReportHandler. - public void onBeforeReportCreated(AndroidJavaObject report, bool isTerminating, AndroidJavaObject completion) - { - completion?.Call("run"); - } - - public void onAfterReportCreated(AndroidJavaObject report, bool isTerminating, AndroidJavaObject completion) - { - completion?.Call("run"); - } - - internal void SetSecureRectangles(IList packedOrEmpty) - { - SetSecureRectangles(0, packedOrEmpty); - } - - internal void SetSecureRectangles(int display, IList packedOrEmpty) - { - if (packedOrEmpty == null || packedOrEmpty.Count < 2) - { - _secureRectsByDisplay[display] = new[] { 1, 0 }; - return; - } - - var arr = new int[packedOrEmpty.Count]; - for (var i = 0; i < packedOrEmpty.Count; i++) - arr[i] = packedOrEmpty[i]; - _secureRectsByDisplay[display] = arr; - } - } -} -#endif diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index 12eb5f3..c591817 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -52,9 +52,9 @@ public void SetAttribute(string key, object value) { } public object GetAttribute(string key) => null; public void ClearAttribute(string key) { } public void ClearAllAttributes() { } - public void AddSecureRectangle(int left, int top, int right, int bottom) { } - public void RemoveSecureRectangle(int left, int top, int right, int bottom) { } - public void RemoveAllSecureRectangles() { } + public void SetSecureBuffer(int display, int[] packed) { } + + public float GetSecureRectSnapshotScale() => 1f; public void CaptureViewHierarchy() { } public void ResetVideoCapturePermission() { } public IAppearance GetAppearance() => new StubAppearance(); diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index 59fc261..81b16fc 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -54,9 +54,11 @@ public interface IBugseeNativeBridge void ClearAttribute(string key); void ClearAllAttributes(); - void AddSecureRectangle(int left, int top, int right, int bottom); - void RemoveSecureRectangle(int left, int top, int right, int bottom); - void RemoveAllSecureRectangles(); + /// Push a versioned secure-rect mask for native wrapper pull (not the public SDK API). + void SetSecureBuffer(int display, int[] packed); + + /// Scale divisor for (1 on Android, screen scale on iOS). + float GetSecureRectSnapshotScale(); void CaptureViewHierarchy(); void ResetVideoCapturePermission(); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 499778f..32a09ed 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -401,13 +401,13 @@ public object GetAttribute(string key) public void ClearAllAttributes() => _bugsee_clear_all_attributes(); - public void AddSecureRectangle(int left, int top, int right, int bottom) => - _bugsee_add_secure_rect(left, top, right - left, bottom - top); - - public void RemoveSecureRectangle(int left, int top, int right, int bottom) => - _bugsee_remove_secure_rect(left, top, right - left, bottom - top); + public void SetSecureBuffer(int display, int[] packed) + { + if (packed == null) return; + _bugsee_set_secure_buffer(display, packed, packed.Length); + } - public void RemoveAllSecureRectangles() => _bugsee_remove_all_secure_rects(); + public float GetSecureRectSnapshotScale() => _bugsee_screen_scale(); public void CaptureViewHierarchy() => _bugsee_capture_view_hierarchy(); @@ -589,9 +589,8 @@ static string JsonString(string s) [DllImport("__Internal")] static extern IntPtr _bugsee_get_email(); [DllImport("__Internal")] static extern void _bugsee_clear_email(); [DllImport("__Internal")] static extern IntPtr _bugsee_get_device_id(); - [DllImport("__Internal")] static extern void _bugsee_add_secure_rect(float x, float y, float w, float h); - [DllImport("__Internal")] static extern void _bugsee_remove_secure_rect(float x, float y, float w, float h); - [DllImport("__Internal")] static extern void _bugsee_remove_all_secure_rects(); + [DllImport("__Internal")] static extern void _bugsee_set_secure_buffer(int display, int[] packed, int packedLength); + [DllImport("__Internal")] static extern float _bugsee_screen_scale(); [DllImport("__Internal")] static extern void _bugsee_capture_view_hierarchy(); [DllImport("__Internal")] static extern void _bugsee_feedback_show(); [DllImport("__Internal")] static extern void _bugsee_feedback_set_greeting(string message); diff --git a/Runtime/WrapperPolicy/SecureRectManualOwners.cs b/Runtime/WrapperPolicy/SecureRectManualOwners.cs new file mode 100644 index 0000000..e189b62 --- /dev/null +++ b/Runtime/WrapperPolicy/SecureRectManualOwners.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// Tracks imperative secure-rect owners keyed by pixel LTRB for the public facade. + public sealed class SecureRectManualOwners + { + public const string OwnerIdPrefix = "manual:"; + + readonly Dictionary _ownerIdByRectKey = new Dictionary(); + + public void Add(int left, int top, int right, int bottom, SecureRectRegistry registry, int displayId) + { + string rectKey = RectKey(left, top, right, bottom); + string ownerId = OwnerIdPrefix + rectKey; + _ownerIdByRectKey[rectKey] = ownerId; + registry.Set(ownerId, displayId, left, top, right, bottom); + } + + public bool Remove(int left, int top, int right, int bottom, SecureRectRegistry registry) + { + string rectKey = RectKey(left, top, right, bottom); + if (!_ownerIdByRectKey.TryGetValue(rectKey, out string ownerId)) + return false; + + registry.RemoveOwner(ownerId); + _ownerIdByRectKey.Remove(rectKey); + return true; + } + + public void RemoveAll(SecureRectRegistry registry) + { + registry.RemoveOwnersWithPrefix(OwnerIdPrefix); + _ownerIdByRectKey.Clear(); + } + + public static string RectKey(int left, int top, int right, int bottom) => + left + "," + top + "," + right + "," + bottom; + } +} diff --git a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs.meta b/Runtime/WrapperPolicy/SecureRectManualOwners.cs.meta similarity index 83% rename from Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs.meta rename to Runtime/WrapperPolicy/SecureRectManualOwners.cs.meta index 2ab9aa9..74f0e5d 100644 --- a/Runtime/Platform/Android/Proxies/BugseeWrapperProxy.cs.meta +++ b/Runtime/WrapperPolicy/SecureRectManualOwners.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b3eb913e27184adbbe59fe2aa28fe675 +guid: a7e3c91f4b2d4e6a9f0c8d5e3b1a7042 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/WrapperPolicy/SecureRectRegistry.cs b/Runtime/WrapperPolicy/SecureRectRegistry.cs index 76e771f..67edcf1 100644 --- a/Runtime/WrapperPolicy/SecureRectRegistry.cs +++ b/Runtime/WrapperPolicy/SecureRectRegistry.cs @@ -5,6 +5,10 @@ namespace Bugsee.WrapperPolicy { public sealed class SecureRectRegistry { + static readonly SecureRectRegistry SharedInstance = new SecureRectRegistry(); + + public static SecureRectRegistry Instance => SharedInstance; + readonly Dictionary<(string ownerId, int displayId), int[]> _rectsByOwnerDisplay = new Dictionary<(string ownerId, int displayId), int[]>(); @@ -45,6 +49,22 @@ public void RemoveOwner(string ownerId) _rectsByOwnerDisplay.Remove(keysToRemove[i]); } + public void RemoveOwnersWithPrefix(string ownerIdPrefix) + { + if (string.IsNullOrEmpty(ownerIdPrefix)) + return; + + var keysToRemove = new List<(string ownerId, int displayId)>(); + foreach (var key in _rectsByOwnerDisplay.Keys) + { + if (key.ownerId != null && key.ownerId.StartsWith(ownerIdPrefix)) + keysToRemove.Add(key); + } + + for (int i = 0; i < keysToRemove.Count; i++) + _rectsByOwnerDisplay.Remove(keysToRemove[i]); + } + public int[] Snapshot(int displayId, float pixelsPerNativeUnit) { ValidatePixelsPerNativeUnit(pixelsPerNativeUnit); diff --git a/Tests~/WrapperPolicy/SecureRectManualOwnerTests.cs b/Tests~/WrapperPolicy/SecureRectManualOwnerTests.cs new file mode 100644 index 0000000..f66fc6c --- /dev/null +++ b/Tests~/WrapperPolicy/SecureRectManualOwnerTests.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class SecureRectManualOwnerTests + { + [Test] + public void Facade_manual_owner_flow_pushes_snapshot_after_each_mutation() + { + var registry = new SecureRectRegistry(); + var owners = new SecureRectManualOwners(); + int[] lastPush = null; + + void PushDisplay0() + { + lastPush = registry.Snapshot(0, 1f); + } + + owners.Add(0, 0, 2, 2, registry, 0); + PushDisplay0(); + Assert.That(lastPush, Is.Not.Null); + Assert.That(lastPush[1], Is.EqualTo(1)); + + owners.Add(5, 5, 8, 8, registry, 0); + PushDisplay0(); + Assert.That(lastPush[1], Is.EqualTo(2)); + int lastNonEmptyVersion = lastPush[0]; + + Assert.That(owners.Remove(0, 0, 2, 2, registry), Is.True); + PushDisplay0(); + Assert.That(lastPush[1], Is.EqualTo(1)); + Assert.That(lastPush[2], Is.EqualTo(5)); + Assert.That(lastPush[3], Is.EqualTo(5)); + Assert.That(lastPush[4], Is.EqualTo(8)); + Assert.That(lastPush[5], Is.EqualTo(8)); + + Assert.That(owners.Remove(5, 5, 8, 8, registry), Is.True); + PushDisplay0(); + Assert.That(lastPush[1], Is.EqualTo(0)); + Assert.That(lastPush[0], Is.GreaterThan(lastNonEmptyVersion)); + Assert.That(lastPush[0], Is.Not.EqualTo(1)); + } + + [Test] + public void Facade_remove_all_manual_owners_then_push_empty_union() + { + var registry = new SecureRectRegistry(); + var owners = new SecureRectManualOwners(); + int[] lastPush = null; + + void PushDisplay0() + { + lastPush = registry.Snapshot(0, 1f); + } + + owners.Add(1, 2, 3, 4, registry, 0); + registry.Set("hud", 0, 10, 10, 20, 20); + PushDisplay0(); + Assert.That(lastPush[1], Is.EqualTo(2)); + int lastNonEmptyVersion = lastPush[0]; + + owners.RemoveAll(registry); + PushDisplay0(); + Assert.That(lastPush[1], Is.EqualTo(1)); + Assert.That(lastPush[2], Is.EqualTo(10)); + + registry.RemoveOwner("hud"); + PushDisplay0(); + Assert.That(lastPush[1], Is.EqualTo(0)); + Assert.That(lastPush[0], Is.GreaterThan(lastNonEmptyVersion)); + } + } +} diff --git a/Tests~/WrapperPolicy/SecureRectSourceTests.cs b/Tests~/WrapperPolicy/SecureRectSourceTests.cs new file mode 100644 index 0000000..b9addb8 --- /dev/null +++ b/Tests~/WrapperPolicy/SecureRectSourceTests.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class SecureRectSourceTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string ExtractMethodBody(string source, string methodSignature) + { + int start = source.IndexOf(methodSignature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Method missing: " + methodSignature); + int brace = source.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) + return source.Substring(start, i - start + 1); + } + } + + throw new InvalidOperationException("Could not extract method body for " + methodSignature); + } + + static string ExtractNativeFunctionSignature(string mm, string functionName) + { + var marker = "void " + functionName; + int start = mm.IndexOf(marker, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Native export missing: " + functionName); + int brace = mm.IndexOf('{', start); + Assert.That(brace, Is.GreaterThanOrEqualTo(0)); + return mm.Substring(start, brace - start).Trim(); + } + + [Test] + public void AndroidBridge_does_not_call_addSecureRectangle() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(android, Does.Not.Contain("addSecureRectangle")); + } + + [Test] + public void Ios_bridge_does_not_push_addSecureRectangle_to_sdk() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + Assert.That(bridge, Does.Not.Contain("addSecureRectangle:")); + } + + [Test] + public void Facade_add_remove_and_clear_push_after_registry_updates() + { + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + string add = ExtractMethodBody(facade, "public static void AddSecureRectangle(RectInt pixelRect)"); + Assert.That(add, Does.Contain("_manualSecureRectOwners.Add")); + Assert.That(add, Does.Contain("SecureRectRegistry.Instance")); + Assert.That(add, Does.Contain("PushSecureBufferForDisplay")); + + string remove = ExtractMethodBody(facade, "public static void RemoveSecureRectangle(RectInt pixelRect)"); + Assert.That(remove, Does.Contain("_manualSecureRectOwners.Remove")); + Assert.That(remove, Does.Contain("PushSecureBufferForDisplay")); + + string removeAll = ExtractMethodBody(facade, "public static void RemoveAllSecureRectangles()"); + Assert.That(removeAll, Does.Contain("_manualSecureRectOwners.RemoveAll")); + Assert.That(removeAll, Does.Contain("PushSecureBufferForDisplay")); + + string push = ExtractMethodBody(facade, "static void PushSecureBufferForDisplay(int displayId)"); + Assert.That(push, Does.Contain("SecureRectRegistry.Instance.Snapshot")); + Assert.That(push, Does.Contain("Bridge.SetSecureBuffer")); + } + + [Test] + public void Ios_bridge_set_secure_buffer_passes_packed_length() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string body = ExtractMethodBody(ios, "public void SetSecureBuffer(int display, int[] packed)"); + Assert.That(body, Does.Contain("packed.Length")); + Assert.That(body, Does.Contain("_bugsee_set_secure_buffer")); + + Assert.That( + ios, + Does.Match(@"extern\s+void\s+_bugsee_set_secure_buffer\s*\(\s*int\s+display\s*,\s*int\s*\[\]\s+packed\s*,\s*int\s+packedLength\s*\)")); + } + + [Test] + public void Ios_callbacks_set_secure_buffer_accepts_explicit_length() + { + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + string signature = ExtractNativeFunctionSignature(callbacks, "_bugsee_set_secure_buffer"); + Assert.That(signature, Does.Contain("int display")); + Assert.That(signature, Does.Contain("int *packed")); + Assert.That(signature, Does.Contain("int packedLength")); + Assert.That(signature, Does.Not.Contain("packed[1]")); + } + + [Test] + public void Android_wrapper_setSecureBuffer_clones_and_getSecureRectangles_reads_cache() + { + string java = File.ReadAllText(RepoFile("Plugins/Android/UnityWrapperProvider.java")); + string setBuffer = ExtractMethodBody(java, "public static void setSecureBuffer(int display, int[] packed)"); + Assert.That(setBuffer, Does.Contain("packed.clone()")); + Assert.That(setBuffer, Does.Contain("secureBuffers.put")); + + string getRects = ExtractMethodBody(java, "public int[] getSecureRectangles(int display)"); + Assert.That(getRects, Does.Contain("secureBuffers.get")); + Assert.That(getRects, Does.Contain("return new int[] { 1, 0 }")); + } + + [Test] + public void Proguard_keeps_setSecureBuffer() + { + string rules = File.ReadAllText(RepoFile("Plugins/Android/bugsee-unity-wrapper.proguard")); + Assert.That(rules, Does.Contain("setSecureBuffer")); + } + } +} From 3ebd406d65b326446cb1c0f36f819da8613a55ea Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 23:03:25 +0500 Subject: [PATCH 075/157] Note the 7.x wrapper alignment in the changelog. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6a85b..894bd61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - IL2CPP native stack capture (`Il2CppNativeStack`) for managed Mode B file/line (`il2cpp_native_stack_trace`; UUID matches Bugsee symbol upload). - Nested `cause` (InnerException / Aggregate) in UnityManagedException JSON. - Docs: competitor matrix + Mode A/B/C for managed C# class/line (Mode B marked in progress until field-proven). +- Pre-launch native wrapper registration: one process-lifetime wrapper is registered before SDK launch—Android `UnityWrapperProvider` (`ContentProvider`, `initOrder` 200), iOS bridge `+load`—instead of constructing a second wrapper from C# `Launch`. +- Wrapper-channel submit: host logs and game-supplied network events go through the stored wrapper channel (Android `UnityWrapper.channelLog` / `channelAddNetwork`; iOS P/Invoke twins), not the public Bugsee log or `addNetworkEvent` APIs. +- Secure-rectangle pull buffer: `AddSecureRectangle`, `RemoveSecureRectangle`, and `RemoveAllSecureRectangles` push a versioned packed buffer; native `getSecureRectangles` (Android) and `secureRectanglesForDisplay:` (iOS) return the last pushed array. +- Fail-closed filter callbacks: network, log, and breadcrumb filters that throw drop the event instead of forwarding the original line. +- Severity `0` is unset, not `VeryLow`: omitted from create-report JSON; `IReport.Severity` is `IssueSeverity?`. +- Public facade: `DeleteCollectedDataOnDevice`, `CreateReport`, `AddBreadcrumb`, and `AddNetworkEvent`. ### Notes From 18028568761c930b4fbff0a243d84ef1ff29a158 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:26:19 +0500 Subject: [PATCH 076/157] Fix a C# syntax error in AndroidReport and add the missing .meta files. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidReport.cs declared 'public static void Invalidate() => checked { _fence++; }'. A checked block cannot be an expression body, so the Android runtime assembly did not compile (found by the first Unity Android build; the CI suites never compile the Unity runtime code). Written as a normal method body. Runtime/WrapperPolicy/AttributePolicy.cs and UserIdentifierPolicy.cs had no .meta files, so Unity generated them on import and left the package dirty. Added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Platform/Android/AndroidReport.cs | 5 ++++- Runtime/WrapperPolicy/AttributePolicy.cs.meta | 11 +++++++++++ Runtime/WrapperPolicy/UserIdentifierPolicy.cs.meta | 11 +++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Runtime/WrapperPolicy/AttributePolicy.cs.meta create mode 100644 Runtime/WrapperPolicy/UserIdentifierPolicy.cs.meta diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index d1fb1a9..7c641b8 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -216,7 +216,10 @@ internal static class AndroidManagedReportUploadFence public static ulong Current => _fence; - public static void Invalidate() => checked { _fence++; } + public static void Invalidate() + { + checked { _fence++; } + } public static bool IsActive(ulong captured) => captured == _fence; } diff --git a/Runtime/WrapperPolicy/AttributePolicy.cs.meta b/Runtime/WrapperPolicy/AttributePolicy.cs.meta new file mode 100644 index 0000000..89253cb --- /dev/null +++ b/Runtime/WrapperPolicy/AttributePolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83a066941c7924eb3b9c3474bc48eafa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/UserIdentifierPolicy.cs.meta b/Runtime/WrapperPolicy/UserIdentifierPolicy.cs.meta new file mode 100644 index 0000000..75237ed --- /dev/null +++ b/Runtime/WrapperPolicy/UserIdentifierPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93d9d95efd4184736a68d7570601bd8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 1c88913a4c9aa6182f60eb4febe396baf1827f69 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:23:52 +0500 Subject: [PATCH 077/157] Bump native SDKs to Android 7.4.1 and iOS 7.0.0-beta6. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the pinned native SDKs to the latest published releases: bugsee-android and bugsee-android-ndk 7.4.1, and bugsee/spm 7.0.0-beta6 (75a25db). - Tools~/versions.env is the single source; the generated constants and the EDM dependency file follow it, and every doc, sample and test fixture that quotes a version is updated. - Tests~/iOS/run-tests.sh pins the beta6 xcframework by SHA-256 (a5dbd8a7...), checked against both the download and the checksum in bugsee/spm's Package.swift. - update-native-sdks.sh regenerated BugseeAndroidDependencies.xml without its explanatory comment; the generator now writes it, so regenerating no longer deletes it. Verified: 151 wrapper policy tests, the iOS bridge tests against the beta6 xcframework, and the Android wrapper unit tests against the 7.4.1 AAR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 4 ++-- DESIGN.md | 8 ++++---- Documentation~/index.md | 8 ++++---- Editor/BugseeAndroidDependencies.xml | 6 +++--- Editor/BugseeAndroidGradleSetup.cs | 6 +++--- Native~/ios/Bugsee/Package.swift | 2 +- README.md | 6 +++--- Runtime/BugseePackageVersion.cs | 6 +++--- .../Assets/Plugins/Android/mainTemplate.gradle | 4 ++-- .../ProjectSettings/AndroidResolverDependencies.xml | 4 ++-- Samples~/BugseeMiniGame/README.md | 4 ++-- Tests~/Android/wrapper/build.gradle | 2 +- Tests~/iOS/run-tests.sh | 6 +++--- Tools~/scripts/update-native-sdks.sh | 6 ++++++ Tools~/scripts/validate-field-symbols.sh | 2 +- Tools~/versions.env | 6 +++--- 16 files changed, 43 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 894bd61..45adfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial UPM package scaffold (`com.bugsee.unity`). -- Android native SDK via EDM4U → Maven (`com.bugsee:bugsee-android:7.3.0` + `bugsee-android-ndk:7.3.0`). Core AAR publishes no transitives; NDK is declared explicitly. +- Android native SDK via EDM4U → Maven (`com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1`). Core AAR publishes no transitives; NDK is declared explicitly. - Android Gradle plugin `4.0.8` (NDK enabled on Unity 6+ launcher templates). -- iOS SPM-only native SDK: Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta5` + `Plugins/iOS/BugseeUnityBridge.mm` P/Invoke bridge (7.x selectors; iOS 15 deployment target). +- iOS SPM-only native SDK: Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` + `Plugins/iOS/BugseeUnityBridge.mm` P/Invoke bridge (7.x selectors; iOS 15 deployment target). - ExceptionPipeline managed capture (`CaptureManagedExceptions`) with UnityManagedException JSON-in-reason. - `Bugsee.LogUnhandledException` on Android and iOS. - Editor IL2CPP linemap upload (`BugseeIl2CppLinemapUpload`) and symbol orchestrator (`BugseeSymbolUpload`: ELF / ProGuard / archive dSYM). diff --git a/DESIGN.md b/DESIGN.md index e7d4f93..63cd801 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,8 +12,8 @@ Legacy foundation: `cross/unity`. Min Unity: **2021.3**. ## Assumptions -1. Android pin: Maven `com.bugsee:bugsee-android:7.3.0` + `bugsee-android-ndk:7.3.0` via EDM4U; Gradle plugin `4.0.8`. Core publishes no transitives (no kotlin-stdlib / fragment pull); NDK must be declared. Feedback AAR stays optional. -2. iOS: SPM-only. Pin [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta5` (`IOS_SDK_VERSION` in `Tools~/versions.env`). Xcode resolves the remote package at export; no CocoaPods and no vendored xcframework. +1. Android pin: Maven `com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` via EDM4U; Gradle plugin `4.0.8`. Core publishes no transitives (no kotlin-stdlib / fragment pull); NDK must be declared. Feedback AAR stays optional. +2. iOS: SPM-only. Pin [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` (`IOS_SDK_VERSION` in `Tools~/versions.env`). Xcode resolves the remote package at export; no CocoaPods and no vendored xcframework. 3. Public C# surface mirrors Android 7.0; intentional C#/.NET deviations allowed (see below). 4. SDK-internal contracts (`capture` aggregators, most `contracts.internal.*` except what wrappers need for `BugseeWrapper`) are **not** public C# API. 5. Optional Gradle network extensions (OkHttp/Ktor/Cronet) are out of default Unity deps unless explicitly added later. @@ -89,8 +89,8 @@ Property / key naming currently follows **Android 7.0**. When the Bugsee **iOS** ## Native packaging (already scaffolded) - `package.json` → `com.bugsee.unity`, depends on `com.google.external-dependency-manager` -- `Editor/BugseeAndroidDependencies.xml` → Maven 7.3.0 (+ NDK, both explicit) -- `Editor/BugseeIosSpmPostProcess.cs` → remote SPM `github.com/bugsee/spm` `7.0.0-beta5` +- `Editor/BugseeAndroidDependencies.xml` → Maven 7.4.1 (+ NDK, both explicit) +- `Editor/BugseeIosSpmPostProcess.cs` → remote SPM `github.com/bugsee/spm` `7.0.0-beta6` - `Plugins/iOS/BugseeUnityBridge.mm` + `Plugins/Android/UnityManagedException.java` - `Tools~/scripts/update-native-sdks.sh` + `versions.env` diff --git a/Documentation~/index.md b/Documentation~/index.md index a91922d..88e5d34 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -6,7 +6,7 @@ Bug reporting, crash capture, and session replay for Unity games and apps on iOS - Unity **2021.3** or newer - [External Dependency Manager for Unity (EDM4U)](https://github.com/googlesamples/unity-jar-resolver) (declared as a package dependency) -- iOS builds: Xcode with Swift Package Manager support; **iOS 15+** (Bugsee 7.0.0-beta5); `bugsee-cli` on the PATH for archive-time symbol upload +- iOS builds: Xcode with Swift Package Manager support; **iOS 15+** (Bugsee 7.0.0-beta6); `bugsee-cli` on the PATH for archive-time symbol upload - Android builds: custom Gradle templates enabled (EDM4U will prompt as needed); `bugsee-cli` on the PATH for Editor symbol upload ## Installation @@ -151,7 +151,7 @@ Device/CI proof remains manual. ### Milestone S2 — Android C# file/line 1. IL2CPP Android build; Create symbols.zip / **FULL** native debug symbols. -2. NDK enabled (`bugsee-android-ndk:7.3.0` + Gradle `ndk { enabled = true }` on Unity 6+). +2. NDK enabled (`bugsee-android-ndk:7.4.1` + Gradle `ndk { enabled = true }` on Unity 6+). 3. Post-build uploads `elf` + `il2cpp-linemap` (multi-ABI UUIDs). 4. Native fatal on device; viewer shows **C# file/line**. @@ -167,8 +167,8 @@ Managed error/crash **grouping** (one signature per event, Unity-primary when st ## Native dependencies -- **Android:** Maven `com.bugsee:bugsee-android:7.3.0` + `com.bugsee:bugsee-android-ndk:7.3.0` via EDM4U (Gradle plugin `4.0.8`). Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. -- **iOS:** SPM-only. Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta5` (iOS 15+). C bridge: `Plugins/iOS/BugseeUnityBridge.mm` + `BugseeUnityCallbacks.mm`. +- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1` via EDM4U (Gradle plugin `4.0.8`). Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. +- **iOS:** SPM-only. Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` (iOS 15+). C bridge: `Plugins/iOS/BugseeUnityBridge.mm` + `BugseeUnityCallbacks.mm`. ### iOS bridge status diff --git a/Editor/BugseeAndroidDependencies.xml b/Editor/BugseeAndroidDependencies.xml index 7ecf70d..7f5413a 100644 --- a/Editor/BugseeAndroidDependencies.xml +++ b/Editor/BugseeAndroidDependencies.xml @@ -1,12 +1,12 @@ - - + + diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 78b7109..a8d45fb 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -15,7 +15,7 @@ namespace Bugsee.Editor /// /// Plugin 4.x requires AGP ≥ 8.6 / Gradle ≥ 8.7 (Unity 6+). On Unity 2021.3 the /// plugin apply is skipped by default. EDM deps: bugsee-android + - /// bugsee-android-ndk 7.3.0 (core publishes no transitives; NDK is explicit). + /// bugsee-android-ndk 7.4.1 (core publishes no transitives; NDK is explicit). /// [InitializeOnLoad] sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport @@ -23,7 +23,7 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport public int callbackOrder => 10; public const string GradlePluginVersion = "4.0.8"; - public const string SdkVersion = "7.3.0"; + public const string SdkVersion = "7.4.1"; const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; const string KotlinExcludeMarker = "// Bugsee: bugsee-android Gradle .module"; @@ -96,7 +96,7 @@ internal static void EnsureProjectTemplates(bool silent) } /// - /// 7.1.4+ (including 7.3.0) ships empty POM and Gradle .module metadata. Drop the kotlin-stdlib + /// 7.1.4+ (including 7.4.1) ships empty POM and Gradle .module metadata. Drop the kotlin-stdlib /// exclusion previously patched into mainTemplate for older .module files. /// static void RemoveStaleKotlinStdlibExclusion() diff --git a/Native~/ios/Bugsee/Package.swift b/Native~/ios/Bugsee/Package.swift index 4cefbde..d9736ce 100644 --- a/Native~/ios/Bugsee/Package.swift +++ b/Native~/ios/Bugsee/Package.swift @@ -2,7 +2,7 @@ import PackageDescription // Local stub kept for offline / UseRemoteSpm = false. -// Default iOS integration is remote SPM: https://github.com/bugsee/spm @ 7.0.0-beta5 +// Default iOS integration is remote SPM: https://github.com/bugsee/spm @ 7.0.0-beta6 // (see Tools~/versions.env and Editor/BugseeIosSpmPostProcess.cs). let package = Package( diff --git a/README.md b/README.md index 4b595b1..50e4d2c 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ Add the OpenUPM scoped registry to your project’s `Packages/manifest.json` if | Platform | Integration | Current pin | |---|---|---| -| **Android** | [EDM4U](https://github.com/googlesamples/unity-jar-resolver) → Maven Central | `bugsee-android:7.3.0` + `bugsee-android-ndk:7.3.0` | -| **iOS** | Swift Package Manager + `Plugins/iOS/BugseeUnityBridge.mm` | [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta5` | +| **Android** | [EDM4U](https://github.com/googlesamples/unity-jar-resolver) → Maven Central | `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` | +| **iOS** | Swift Package Manager + `Plugins/iOS/BugseeUnityBridge.mm` | [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` | **Android stack** | Piece | Status | |---|---| -| `bugsee-android` | Default via EDM **7.3.0** (no Maven transitives) | +| `bugsee-android` | Default via EDM **7.4.1** (no Maven transitives) | | `bugsee-android-ndk` | Declared explicitly (native fatal capture for IL2CPP primary LNM) | | Gradle plugin `4.0.8` | Applied on launcher by `BugseeAndroidGradleSetup` on Unity 6+ (`ndk { enabled = true }`) | diff --git a/Runtime/BugseePackageVersion.cs b/Runtime/BugseePackageVersion.cs index 7af606b..6c2e183 100644 --- a/Runtime/BugseePackageVersion.cs +++ b/Runtime/BugseePackageVersion.cs @@ -7,9 +7,9 @@ public static class BugseePackageVersion { public const string Version = "0.1.0"; public const string Build = "dev"; - public const string AndroidSdkVersion = "7.3.0"; + public const string AndroidSdkVersion = "7.4.1"; public const string IosSdkSource = "https://github.com/bugsee/spm.git"; - public const string IosSdkVersion = "7.0.0-beta5"; - public const string IosSdkCommit = "339f634bfdd1ba0ebcd96ca1d88d790adb64eb52"; + public const string IosSdkVersion = "7.0.0-beta6"; + public const string IosSdkCommit = "75a25db8e5071a5133710f21f10265a804659de8"; } } diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle b/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle index 5edd814..5f41343 100644 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle +++ b/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle @@ -4,8 +4,8 @@ apply plugin: 'com.android.library' dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) // Android Resolver Dependencies Start - implementation 'com.bugsee:bugsee-android:7.3.0' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml - implementation 'com.bugsee:bugsee-android-ndk:7.3.0' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml + implementation 'com.bugsee:bugsee-android:7.4.1' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml + implementation 'com.bugsee:bugsee-android-ndk:7.4.1' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml // Android Resolver Dependencies End **DEPS**} diff --git a/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml b/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml index f889e45..53d79c0 100644 --- a/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml +++ b/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml @@ -1,7 +1,7 @@ - com.bugsee:bugsee-android:7.3.0 - com.bugsee:bugsee-android-ndk:7.3.0 + com.bugsee:bugsee-android:7.4.1 + com.bugsee:bugsee-android-ndk:7.4.1 diff --git a/Samples~/BugseeMiniGame/README.md b/Samples~/BugseeMiniGame/README.md index e625ec2..1f49d12 100644 --- a/Samples~/BugseeMiniGame/README.md +++ b/Samples~/BugseeMiniGame/README.md @@ -59,7 +59,7 @@ SetSecureRect / SetAttribute / SetUserIdentifier / Appearance open IMGUI forms f |----------|-------------| | **Editor** | Game + HUD work; Bugsee uses no-op bridge (`IsLaunched` stays false). | | **Android** | Full path: Launch with `DirectBuffers`, reporting, blackout, filters, etc. | -| **iOS** | Launch + managed/native crash path via `BugseeUnityBridge` + remote SPM (`github.com/bugsee/spm` `7.0.0-beta5`). Requires **iOS 15+** (sample Player Settings and the export post-process both set `IPHONEOS_DEPLOYMENT_TARGET` to 15.0). | +| **iOS** | Launch + managed/native crash path via `BugseeUnityBridge` + remote SPM (`github.com/bugsee/spm` `7.0.0-beta6`). Requires **iOS 15+** (sample Player Settings and the export post-process both set `IPHONEOS_DEPLOYMENT_TARGET` to 15.0). | ## Build Android @@ -67,7 +67,7 @@ SetSecureRect / SetAttribute / SetUserIdentifier / Appearance open IMGUI forms f 2. Set a real Bugsee app token on `MiniGameBootstrap`. 3. Build & Run on device. -EDM pulls `bugsee-android:7.3.0` + `bugsee-android-ndk:7.3.0` (core publishes no transitives; NDK is explicit). +EDM pulls `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` (core publishes no transitives; NDK is explicit). Gradle plugin `4.0.8` is applied on Unity 6+ launcher templates (`ndk { enabled = true }`). ### EDM resolve fails with `org.codehaus.groovy.vmplugin.v7.Java7` diff --git a/Tests~/Android/wrapper/build.gradle b/Tests~/Android/wrapper/build.gradle index 57e1432..c0fc6c9 100644 --- a/Tests~/Android/wrapper/build.gradle +++ b/Tests~/Android/wrapper/build.gradle @@ -27,6 +27,6 @@ android { } dependencies { - implementation "com.bugsee:bugsee-android:7.3.0" + implementation "com.bugsee:bugsee-android:7.4.1" testImplementation "junit:junit:4.13.2" } diff --git a/Tests~/iOS/run-tests.sh b/Tests~/iOS/run-tests.sh index c90bc8b..9c045de 100644 --- a/Tests~/iOS/run-tests.sh +++ b/Tests~/iOS/run-tests.sh @@ -3,9 +3,9 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)" BUILD="$ROOT/Tests~/iOS/.build" -ZIP="$BUILD/Bugsee-7.0.0-beta5.zip" -URL="https://download.bugsee.com/sdk/ios/spm/Bugsee-7.0.0-beta5.zip" -CHECKSUM="af8f9670fddd3f94d7ee7d7fe2b3696c2d9f46ad49e5e523758a10e29f6f8123" +ZIP="$BUILD/Bugsee-7.0.0-beta6.zip" +URL="https://download.bugsee.com/sdk/ios/spm/Bugsee-7.0.0-beta6.zip" +CHECKSUM="a5dbd8a7681fbda9ce269431a1babc37ea054da99b79092bb8453962e0f117ad" mkdir -p "$BUILD" if [ ! -f "$ZIP" ] || ! printf '%s %s\n' "$CHECKSUM" "$ZIP" | shasum -a 256 -c - >/dev/null 2>&1; then diff --git a/Tools~/scripts/update-native-sdks.sh b/Tools~/scripts/update-native-sdks.sh index 5c54d53..ecffd19 100755 --- a/Tools~/scripts/update-native-sdks.sh +++ b/Tools~/scripts/update-native-sdks.sh @@ -47,6 +47,12 @@ echo "Updated ${RUNTIME_VERSION_CS}" cat > "${ANDROID_DEPS_XML}" < + diff --git a/Tools~/scripts/validate-field-symbols.sh b/Tools~/scripts/validate-field-symbols.sh index a3bb3e2..dc25c7b 100755 --- a/Tools~/scripts/validate-field-symbols.sh +++ b/Tools~/scripts/validate-field-symbols.sh @@ -78,7 +78,7 @@ do fi done -ok "iOS native SDK via remote SPM (github.com/bugsee/spm ${IOS_SDK_VERSION:-7.0.0-beta5})" +ok "iOS native SDK via remote SPM (github.com/bugsee/spm ${IOS_SDK_VERSION:-7.0.0-beta6})" extract_macho_uuid() { local bin="$1" diff --git a/Tools~/versions.env b/Tools~/versions.env index 9d7c987..440fe93 100644 --- a/Tools~/versions.env +++ b/Tools~/versions.env @@ -4,7 +4,7 @@ BUGSEE_PACKAGE_VERSION="0.1.0" # Latest published Android 7.x on Maven Central -ANDROID_SDK_VERSION="7.3.0" +ANDROID_SDK_VERSION="7.4.1" ANDROID_MAVEN_GROUP="com.bugsee" ANDROID_MAVEN_ARTIFACT="bugsee-android" ANDROID_FEEDBACK_ARTIFACT="bugsee-android-feedback" @@ -13,6 +13,6 @@ ANDROID_GRADLE_PLUGIN_VERSION="4.0.8" # iOS: public SPM feed (binary xcframework). Not CocoaPods / not a local clone. IOS_SDK_REPO="https://github.com/bugsee/spm.git" -IOS_SDK_VERSION="7.0.0-beta5" -IOS_SDK_COMMIT="339f634bfdd1ba0ebcd96ca1d88d790adb64eb52" +IOS_SDK_VERSION="7.0.0-beta6" +IOS_SDK_COMMIT="75a25db8e5071a5133710f21f10265a804659de8" IOS_SPM_PRODUCT="Bugsee" From 5a5cb37d678219a322f8328958db3ec7b3a8ec39 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:24:31 +0500 Subject: [PATCH 078/157] Stop iOS GetAttribute from crashing on scalar attribute values. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _bugsee_get_attribute checked isValidJSONObject:@[attr] (wrapped in an array) but then serialized attr itself. NSJSONSerialization dataWithJSONObject: raises NSInvalidArgumentException ("Invalid top-level type in JSON write") for a bare string, number or bool, so Bugsee.SetAttribute(key, "text"/1/true) aborted the app: IOSBridge.SetAttribute reads the value back after setting it, and every value C# can send is a scalar. Only containers are serialized now; scalars take the existing description fallback. Behaviour to know: scalars read back as their text form, so the string "1", the number 1 and the bool true all read as "1" (false as "0"). Android returns typed values. Nothing in the repo depends on the type; the comment in IOSBridge.GetAttribute says so now. Test: the iOS bridge test sets and reads back a string, number, decimal, bools, empty and non-ASCII text and asserts the values (it previously only asserted "did not crash"). It aborts (signal 6) without this change and passes with it; the SDK keeps attributes in memory without being launched, so the path is exercised. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Plugins/iOS/BugseeUnityBridge.mm | 4 +++- Runtime/Platform/IOS/IOSBridge.cs | 3 ++- Tests~/iOS/BridgeTests.mm | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 13bdb11..b898894 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -360,7 +360,9 @@ void _bugsee_set_attribute(const char *key, const char *valueJson) if (!attr) { return NULL; } - if ([NSJSONSerialization isValidJSONObject:@[attr]]) { + // dataWithJSONObject: throws on a bare string/number, so only serialize containers. + if (([attr isKindOfClass:[NSDictionary class]] || [attr isKindOfClass:[NSArray class]]) && + [NSJSONSerialization isValidJSONObject:attr]) { NSData *data = [NSJSONSerialization dataWithJSONObject:attr options:0 error:nil]; if (data) { NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 32a09ed..1dc9190 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -393,7 +393,8 @@ public void SetAttribute(string key, object value) public object GetAttribute(string key) { - // Native returns JSON when possible, otherwise description text. + // Containers come back as JSON; scalars come back as their text form, so a string "1", + // the number 1 and the bool true all read as "1" (Android returns typed values). return ConsumeNativeString(_bugsee_get_attribute(key ?? "")); } diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index b6330a6..a0833ef 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -9,6 +9,9 @@ const char *_bugsee_test_copy_wrapper_build(void); int _bugsee_test_wrapper_installed(void); const char *_bugsee_test_copy_context_value(const char *key); +void _bugsee_set_attribute(const char *key, const char *valueJson); +char *_bugsee_get_attribute(const char *key); +void _bugsee_clear_attribute(const char *key); } static void expect(int condition, const char *message) @@ -56,6 +59,27 @@ int main(void) expect(same(value, "ci"), "ensure wrapper stores the build"); free((void *)value); + // A scalar attribute must not throw from NSJSONSerialization (top-level non-container), and + // reads back as its text form: the string, the number, 1/0 for a bool. + struct { const char *name; const char *json; const char *expected; } scalars[] = { + { "text", "\"text\"", "text" }, + { "number", "1", "1" }, + { "decimal", "1.5", "1.5" }, + { "yes", "true", "1" }, + { "no", "false", "0" }, + { "empty", "\"\"", "" }, + { "unicode", "\"h\\u00e9llo\"", "h\xc3\xa9llo" }, + }; + for (auto &c : scalars) { + _bugsee_set_attribute(c.name, c.json); + char *attr = _bugsee_get_attribute(c.name); + char message[96]; + snprintf(message, sizeof message, "scalar attribute %s reads back as its text", c.name); + expect(attr != NULL && same(attr, c.expected), message); + free(attr); + _bugsee_clear_attribute(c.name); + } + printf("ios bridge tests passed\n"); return 0; } From 8d0f3b6b147be17b5d7a8e359e078dcd36216544 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:25:35 +0500 Subject: [PATCH 079/157] Android: register the wrapper provider by injecting it into the generated manifest. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnityWrapperProvider exists so the wrapper is registered before the SDK launches: a crash recovered on the next start is assembled during launch, and a wrapper registered from C# is too late for it (wrapper workbook, Part 2). Unity 2021.3 does not merge a package's loose Plugins/Android/AndroidManifest.xml. Verified against APKs built with Unity 2021.3.45f1 (Android module installed in the Hub editor directory): UnityWrapper is in classes2.dex, but the merged manifest had no UnityWrapperProvider, so early registration silently never happened (a normal launch still worked, because C# registers the wrapper later). With this change the built APK's manifest lists the provider at initOrder 200 (0xc8), not exported, direct-boot aware, under a unique authority, and `pm dump` on the installed app shows it. An IPostGenerateGradleAndroidProject step adds the elements described in Plugins/Android/AndroidManifest.xml to the generated unityLibrary manifest (src/main/AndroidManifest.xml), once (it skips a provider that is already declared). Only elements are copied. The class is not behind a UNITY_ANDROID define: IPostGenerateGradleAndroidProject is defined in UnityEditor.CoreModule (checked with monodis on 2021.3.45), so it compiles for every target and only fires for Android builds; a guard would drop it silently when the editor's active target is not Android at compile time. Failures are logged with Debug.LogError, not thrown, so they do not stop a build - except under BuildOptions.StrictMode, where a logged error fails it (arguably right: a missing provider is a silent data-loss bug). Tests: a source test pins the injector (interface, no platform guard, target path) and the provider template. The injection itself needs a Unity Android build; it was checked by inspecting the built APK. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeAndroidWrapperManifest.cs | 97 +++++++++++++++++++ Editor/BugseeAndroidWrapperManifest.cs.meta | 11 +++ .../WrapperPolicy/RegistrationSourceTests.cs | 20 ++++ 3 files changed, 128 insertions(+) create mode 100644 Editor/BugseeAndroidWrapperManifest.cs create mode 100644 Editor/BugseeAndroidWrapperManifest.cs.meta diff --git a/Editor/BugseeAndroidWrapperManifest.cs b/Editor/BugseeAndroidWrapperManifest.cs new file mode 100644 index 0000000..a0a0fe8 --- /dev/null +++ b/Editor/BugseeAndroidWrapperManifest.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using UnityEditor.Android; +using UnityEngine; + +namespace Bugsee.Editor +{ + /// + /// Puts UnityWrapperProvider into the merged app manifest. + /// + /// The provider has to exist before Bugsee launches, otherwise a crash recovered on the next + /// start is assembled without the wrapper (no identity, no secure rectangles, no report hooks). + /// Unity does not merge a loose AndroidManifest.xml shipped in a package's + /// Plugins/Android (verified on 2021.3: the Java compiles into the APK, the manifest entry + /// does not), so the entry is injected into the generated unityLibrary manifest here. + /// + /// Plugins/Android/AndroidManifest.xml stays the single description of the provider. Only its + /// <provider> elements are copied: another kind of entry added to it is not injected. + /// + /// Deliberately not wrapped in a UNITY_ANDROID define guard: IPostGenerateGradleAndroidProject is defined in + /// UnityEditor.CoreModule (checked on 2021.3.45), so it compiles for every build target, and the callback only + /// fires for Android builds. Guarding it would drop it silently whenever the editor's active target is not + /// Android when the scripts compile. + /// + sealed class BugseeAndroidWrapperManifest : IPostGenerateGradleAndroidProject + { + static readonly XNamespace Android = "http://schemas.android.com/apk/res/android"; + + public int callbackOrder => 20; + + public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) + { + try + { + var template = ResolveTemplatePath(); + if (template == null) + { + Debug.LogError( + "[Bugsee] Could not find Plugins/Android/AndroidManifest.xml in com.bugsee.unity; " + + "the wrapper provider was not registered, so crashes recovered on relaunch will lack wrapper data."); + return; + } + + var target = Path.Combine(unityLibraryPath, "src", "main", "AndroidManifest.xml"); + if (!File.Exists(target)) + { + Debug.LogError("[Bugsee] unityLibrary manifest not found at " + target + "; wrapper provider not registered."); + return; + } + + var added = InjectProviders(XDocument.Load(template), target); + if (added > 0) + Debug.Log("[Bugsee] Registered " + added + " wrapper provider(s) in " + target); + } + catch (Exception ex) + { + // Never fail the build, but be loud: a missing provider is a silent data-loss bug. + Debug.LogError("[Bugsee] Failed to register the wrapper provider: " + ex); + } + } + + static int InjectProviders(XDocument template, string targetPath) + { + var providers = template.Descendants("provider").ToList(); + var target = XDocument.Load(targetPath); + var application = target.Root?.Element("application"); + if (application == null) + { + application = new XElement("application"); + target.Root?.Add(application); + } + + var added = 0; + foreach (var provider in providers) + { + var name = (string)provider.Attribute(Android + "name"); + var exists = application.Elements("provider").Any(p => (string)p.Attribute(Android + "name") == name); + if (exists) continue; + application.Add(new XElement(provider)); + added++; + } + + if (added > 0) target.Save(targetPath); + return added; + } + + static string ResolveTemplatePath() + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(BugseeAndroidWrapperManifest).Assembly); + if (info == null) return null; + var path = Path.Combine(info.resolvedPath, "Plugins", "Android", "AndroidManifest.xml"); + return File.Exists(path) ? path : null; + } + } +} diff --git a/Editor/BugseeAndroidWrapperManifest.cs.meta b/Editor/BugseeAndroidWrapperManifest.cs.meta new file mode 100644 index 0000000..a8544de --- /dev/null +++ b/Editor/BugseeAndroidWrapperManifest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca7beef3c1e94bab9385b10e28d12a2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/RegistrationSourceTests.cs b/Tests~/WrapperPolicy/RegistrationSourceTests.cs index 2ff8652..9aaf57b 100644 --- a/Tests~/WrapperPolicy/RegistrationSourceTests.cs +++ b/Tests~/WrapperPolicy/RegistrationSourceTests.cs @@ -42,5 +42,25 @@ public void Provider_class_name_survives_the_gradle_plugin_strip() Assert.That(java, Does.Contain("initOrder")); Assert.That(java, Does.Contain("Bugsee.setWrapper")); } + + // Unity 2021.3 compiles the provider's Java into the APK but does not merge a package's + // loose Plugins/Android/AndroidManifest.xml, so without this step the provider is absent + // and early registration silently never happens. Verified against a real APK build. + [Test] + public void Provider_is_injected_into_the_generated_gradle_manifest() + { + string injector = File.ReadAllText(RepoFile("Editor/BugseeAndroidWrapperManifest.cs")); + Assert.That(injector, Does.Contain("IPostGenerateGradleAndroidProject")); + // A platform guard would drop the callback when the active build target is not Android + // at compile time; the interface exists for every target. + Assert.That(injector, Does.Not.Contain("#if UNITY_ANDROID")); + Assert.That(injector, Does.Contain("\"src\", \"main\"")); + Assert.That(injector, Does.Contain("\"AndroidManifest.xml\"")); + Assert.That(injector, Does.Contain("\"provider\"")); + + string template = File.ReadAllText(RepoFile("Plugins/Android/AndroidManifest.xml")); + Assert.That(template, Does.Contain("com.bugsee.unity.UnityWrapperProvider")); + Assert.That(template, Does.Contain("android:initOrder=\"200\"")); + } } } From 74d7443d015248d68521d849e90f80caaafd2e4f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:26:00 +0500 Subject: [PATCH 080/157] Android: throw TestCrash from the main looper so it is an uncaught crash. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugsee.testCrash() throws a RuntimeException (BugseeInternal.testCrash in 7.4.1). Called through JNI on Unity's thread, the exception comes back to C# as a caught AndroidJavaException; Unity logs it and nothing is uncaught, so the SDK reported a handled AndroidJavaException and the app kept running. A Unity user running TestCrash got no crash report. UnityWrapper.testCrash() now posts the call to the Android main looper, where the exception is uncaught as in a normal Android app and the SDK's uncaught-exception handler reports it as a crash. Observed on an emulator: FATAL EXCEPTION on the main thread, and the crash is reported as java.lang.RuntimeException at UnityWrapper$1.run (the first frame in the raw logcat is BugseeInternal.testCrash, with the runnable below it). TestCrash now returns before the process dies. The sample's callers set a status and do nothing after the call, so nothing depends on the old synchronous behaviour. R8: the JNI-called method is kept in its own rule, and the anonymous runnable's class name is kept (-keepnames) so the frame stays readable in a minified build without the mapping. Tests: a JVM test pins that testCrash exists in the compiled class, is kept by the rules, and is called by the bridge by name. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Plugins/Android/UnityWrapperProvider.java | 14 ++++++++++++++ Plugins/Android/bugsee-unity-wrapper.proguard | 6 ++++++ Runtime/Platform/Android/AndroidBridge.cs | 9 ++++++++- .../java/com/bugsee/unity/AndroidWrapperTests.java | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index b323dd6..462124e 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -123,6 +123,20 @@ public static void channelAddNetwork(com.bugsee.library.contracts.exchange.Netwo current.addNetworkEvent(event, true); } + /** + * Bugsee.testCrash() throws a RuntimeException. Called from Unity's thread over JNI it would + * only surface as a managed AndroidJavaException that Unity catches and logs, so no crash + * would ever be reported. Throwing from the main looper leaves it uncaught, like a real crash. + */ + public static void testCrash() { + new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + Bugsee.testCrash(); + } + }); + } + public static void setSecureBuffer(int display, int[] packed) { if (packed == null || packed.length < 2) return; secureBuffers.put(display, packed.clone()); diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard index 9234aee..240085c 100644 --- a/Plugins/Android/bugsee-unity-wrapper.proguard +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -7,3 +7,9 @@ static void setSecureBuffer(int, int[]); } -keep class com.bugsee.unity.UnityWrapperProvider { *; } +# C# calls UnityWrapper.testCrash() via JNI. Its anonymous Runnable (UnityWrapper$1) is what appears in the +# crash's stack frame, so keep its name readable instead of relying on the mapping file. +-keep class com.bugsee.unity.UnityWrapper { + static void testCrash(); +} +-keepnames class com.bugsee.unity.UnityWrapper$* diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 615b094..ab9550e 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -300,7 +300,14 @@ void SendManagedPayload( } } - public void TestCrash() => _bugsee.CallStatic("testCrash"); + // Not Bugsee.testCrash() directly: thrown over JNI on Unity's thread it never goes uncaught. + public void TestCrash() + { + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) + { + wrapper.CallStatic("testCrash"); + } + } public void ShowReportDialog(string summary, string description, IssueSeverity severity, IList labels) { diff --git a/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java index a603725..51682cd 100644 --- a/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java +++ b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java @@ -43,6 +43,20 @@ public void compiledWrapperDeclaresTheSdkContract() throws Exception { assertTrue(classPool("com/bugsee/unity/UnityWrapperProvider.class").contains("UnityWrapperProvider")); } + @Test + public void testCrashIsPostedFromJavaAndKeptForJni() throws Exception { + // C# calls UnityWrapper.testCrash() by name over JNI; a rename on either side would go unnoticed. + assertTrue(classPool("com/bugsee/unity/UnityWrapper.class").contains("testCrash")); + String rules = new String( + Files.readAllBytes(repoFile("Plugins/Android/bugsee-unity-wrapper.proguard")), + StandardCharsets.UTF_8); + assertTrue(rules.contains("static void testCrash();")); + String bridge = new String( + Files.readAllBytes(repoFile("Runtime/Platform/Android/AndroidBridge.cs")), + StandardCharsets.UTF_8); + assertTrue(bridge.contains("\"testCrash\"")); + } + static String classPool(String relative) throws Exception { byte[] bytes = Files.readAllBytes(findUnderBuild(relative)); return new String(bytes, StandardCharsets.ISO_8859_1); From 69f31bb10519245b89548f06c85df1e30a04915b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:25:59 +0500 Subject: [PATCH 081/157] Android: report the real wrapper identity early and deliver lifecycle events from before C# starts, exactly once. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnityWrapperProvider registers the wrapper before any C# runs, but it answered getWrapperVersion with "unknown" and getWrapperBuild with the literal "unknown" until the first managed Launch called refineContext, so a crash recovered at startup, or an auto-launch, was reported with version "unknown" and build "unknown". Its onLifecycleEvent was empty, so every event fired before the C# listener existed (Launching, Launched, RelaunchedAfterCrash...) was dropped. Identity: UnityWrapperIdentity (written by update-native-sdks.sh) holds the version and build known at build time, and the provider reports them from the first call. The build is whatever the generator wrote - the git sha of the checkout it ran in, or "dev" when git is unavailable (and the committed placeholder is "dev") - and getWrapperBuild returns null only for an empty string. So a build made without running the generator reports "dev", like the iOS bridge does; run the generator at release time. refineContext still sets the same version from C#. WrapperIdentityDriftTests fails when the package version, the Java identity version or build, or the native SDK pins disagree (mutation-checked: changing one copy makes it fail). Lifecycle events reach C# through ONE path, the wrapper. Verified against BugseeLifecycle.dispatchEvent (7.3.0 and 7.4.1): every event goes through one function that gives the same objects to wrapper.onLifecycleEvent and then to the app listener on a single aux thread, so no event reaches the app listener without also reaching the wrapper, and dropping Bugsee.setLifecycleEventsListener loses nothing. Events that arrive before C# attaches are held (the earliest 128 are kept; later ones are dropped, so Launching and RelaunchedAfterCrash survive a burst). UnityWrapper.attachLifecycleListener then replays them in order with their ORIGINAL data object, and forwards every later event. The replay runs outside the lock, on the Android main looper, so app code never runs under the lock or inside the C# Launch call; events that arrive meanwhile queue behind it and are flushed in order before the listener becomes the direct destination, so nothing overtakes it; a throwing handler is caught per event, so it cannot lose the ones after it. Attaching the same listener again does nothing. C# no longer registers the same listener with Bugsee.setLifecycleEventsListener: doing both leaves a window in which an event is lost (neither path ready) or delivered twice (both ready). Not changed: the replay goes to the listener installed when the bridge first sets it up, which happens at the first Launch or SetLifecycleEventListener call. Handlers subscribed to Bugsee.LifecycleEvent after that miss the startup events (subscribe before launching, as the SDK requires). Multi-process apps: the provider runs only in the default process. The generator no longer aborts on an offline `git ls-remote` (set -e with pipefail), which previously stopped it before the identity file was written. Tests: 11 JVM tests ran and passed (replay order and identity of the data object, exactly-once forwarding, attach-twice, events arriving during the replay stay ordered, a re-entrant handler, a throwing handler, the 128 cap keeps the earliest, null handling, identity). Policy tests pin that the bridge attaches through the wrapper and no longer calls setLifecycleEventsListener. Behaviour on a device with a recovered crash was not exercised. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Plugins/Android/UnityWrapperIdentity.java | 13 ++ .../Android/UnityWrapperIdentity.java.meta | 32 ++++ Plugins/Android/UnityWrapperProvider.java | 111 ++++++++++- Plugins/Android/bugsee-unity-wrapper.proguard | 1 + Runtime/Platform/Android/AndroidBridge.cs | 10 +- .../com/bugsee/unity/AndroidWrapperTests.java | 177 ++++++++++++++++++ .../WrapperIdentityDriftTests.cs | 70 +++++++ Tools~/scripts/update-native-sdks.sh | 22 ++- 8 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 Plugins/Android/UnityWrapperIdentity.java create mode 100644 Plugins/Android/UnityWrapperIdentity.java.meta create mode 100644 Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs diff --git a/Plugins/Android/UnityWrapperIdentity.java b/Plugins/Android/UnityWrapperIdentity.java new file mode 100644 index 0000000..ddb3b4b --- /dev/null +++ b/Plugins/Android/UnityWrapperIdentity.java @@ -0,0 +1,13 @@ +package com.bugsee.unity; + +/** + * Wrapper identity known at build time. Regenerated by Tools~/scripts/update-native-sdks.sh. + * Lets the early (provider-time) registration report the real version and build instead of + * "unknown" for a crash recovered before any C# runs. + */ +final class UnityWrapperIdentity { + static final String VERSION = "0.1.0"; + static final String BUILD = "dev"; + + private UnityWrapperIdentity() {} +} diff --git a/Plugins/Android/UnityWrapperIdentity.java.meta b/Plugins/Android/UnityWrapperIdentity.java.meta new file mode 100644 index 0000000..3cf2cb2 --- /dev/null +++ b/Plugins/Android/UnityWrapperIdentity.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: d61d56b842104307a16b3b0ff509f914 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Plugins/Android/UnityWrapperProvider.java b/Plugins/Android/UnityWrapperProvider.java index b323dd6..f8b47cb 100644 --- a/Plugins/Android/UnityWrapperProvider.java +++ b/Plugins/Android/UnityWrapperProvider.java @@ -5,10 +5,16 @@ import com.bugsee.library.contracts.common.DataRequestResultCallback; import com.bugsee.library.contracts.internal.BugseeWrapper; import com.bugsee.library.contracts.internal.BugseeWrapperChannel; +import com.bugsee.library.contracts.lifecycle.LifecycleEventListener; import com.bugsee.library.contracts.reporting.Report; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; // initOrder 200 public final class UnityWrapperProvider extends BugseeExtensionInitProviderBase { @@ -27,7 +33,7 @@ final class UnityWrapper implements BugseeWrapper { private String platform = "unknown"; private String scriptingBackend = "unknown"; private String productName = "unknown"; - private String wrapperVersion = "unknown"; + private String wrapperVersion = UnityWrapperIdentity.VERSION; private static volatile BugseeWrapperChannel channel; private static final ConcurrentHashMap secureBuffers = new ConcurrentHashMap(); @@ -68,7 +74,9 @@ public String getWrapperVersion() { @Override public String getWrapperBuild() { - return "unknown"; + // The contract says null when there is no build; "unknown" would be reported as one. + String build = UnityWrapperIdentity.BUILD; + return build == null || build.length() == 0 ? null : build; } @Override @@ -86,9 +94,106 @@ public void requestData(String dataType, DataRequestResultCallback callback) { if (callback != null) callback.onResult(null); } + // Lifecycle events reach C# through ONE path: this wrapper. The SDK calls onLifecycleEvent for every event + // from the moment the provider registers it - before any C# exists (auto-launch, crash recovery) - so the + // events that arrive first are held, and attachLifecycleListener() replays them in order to the managed + // listener and then makes it the destination of everything after. Registering the managed listener with + // Bugsee.setLifecycleEventsListener as well would leave a window in which an event is either lost (neither + // path ready) or delivered twice (both ready); with a single path there is none. + // + // The replay runs OUTSIDE the lock, on the Android main looper, so app code never runs under the lock or + // inside the C# Launch call. Events that arrive while it runs queue behind it and are flushed in order + // before the listener becomes the direct destination, so nothing overtakes it. + private static final String TAG = "BugseeUnity"; + private static final int MAX_EARLY_LIFECYCLE_EVENTS = 128; + private static final Object lifecycleLock = new Object(); + private static ArrayList earlyLifecycle = new ArrayList(); + private static ArrayList duringReplay; // non-null while a replay is running + private static LifecycleEventListener lifecycleListener; + /** Where the replay runs; tests substitute a direct or capturing executor (there is no Looper on the JVM). */ + static volatile Executor replayExecutor = new Executor() { + @Override + public void execute(Runnable task) { + new Handler(Looper.getMainLooper()).post(task); + } + }; + @Override public void onLifecycleEvent(String eventType, Object data) { - // Wrapper lifecycle is forwarded via setLifecycleEventsListener on the Bugsee facade. + if (eventType == null) return; + LifecycleEventListener target; + synchronized (lifecycleLock) { + if (duringReplay != null) { + if (duringReplay.size() < MAX_EARLY_LIFECYCLE_EVENTS) duringReplay.add(new Object[] { eventType, data }); + return; + } + target = lifecycleListener; + if (target == null) { + // Keep the earliest events (Launching, RelaunchedAfterCrash...); once full, later ones are + // dropped rather than the ones that explain how the process started. + if (earlyLifecycle.size() < MAX_EARLY_LIFECYCLE_EVENTS) { + earlyLifecycle.add(new Object[] { eventType, data }); + } + return; + } + } + deliver(target, eventType, data); + } + + /** + * Replays the held events, in order and with their original data, to the managed listener, then forwards + * every later event to it. Attaching the same listener again does nothing. + */ + public static void attachLifecycleListener(final LifecycleEventListener listener) { + if (listener == null) return; + final ArrayList first; + synchronized (lifecycleLock) { + if (lifecycleListener == listener || duringReplay != null) return; + if (lifecycleListener != null) { + Log.w(TAG, "Replacing the lifecycle listener; events held earlier were already delivered."); + lifecycleListener = listener; + return; + } + first = earlyLifecycle; + earlyLifecycle = new ArrayList(); + duringReplay = new ArrayList(); + } + replayExecutor.execute(new Runnable() { + @Override + public void run() { + ArrayList batch = first; + while (true) { + for (Object[] event : batch) deliver(listener, (String) event[0], event[1]); + synchronized (lifecycleLock) { + if (duringReplay.isEmpty()) { + lifecycleListener = listener; + duringReplay = null; + return; + } + batch = duringReplay; + duringReplay = new ArrayList(); + } + } + } + }); + } + + // The SDK guards the listener it calls; so does this, so one throwing handler cannot lose the events after it. + private static void deliver(LifecycleEventListener listener, String eventType, Object data) { + try { + listener.onEvent(eventType, data); + } catch (Throwable t) { + Log.w(TAG, "The lifecycle listener threw for " + eventType, t); + } + } + + /** Test hook: back to the state before any listener was attached. */ + static void resetLifecycleForTest() { + synchronized (lifecycleLock) { + lifecycleListener = null; + duringReplay = null; + earlyLifecycle = new ArrayList(); + } } @Override diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard index 9234aee..f63d302 100644 --- a/Plugins/Android/bugsee-unity-wrapper.proguard +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -5,5 +5,6 @@ static void channelAddNetwork(com.bugsee.library.contracts.exchange.NetworkEvent); static void clearWrapperChannel(); static void setSecureBuffer(int, int[]); + static void attachLifecycleListener(com.bugsee.library.contracts.lifecycle.LifecycleEventListener); } -keep class com.bugsee.unity.UnityWrapperProvider { *; } diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 615b094..667346d 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -549,10 +549,14 @@ public void AddBreadcrumb(string category, string message, string levelName) void InstallDefaultListeners() { - if (_lifecycleProxy == null) + if (_lifecycleProxy != null) return; + // Lifecycle events reach C# only through the wrapper: it holds the ones fired before this point + // (auto-launch, crash recovery), replays them in order, then forwards every later event. Also + // registering with Bugsee.setLifecycleEventsListener would deliver events twice or lose some. + _lifecycleProxy = new LifecycleListenerProxy(); + using (var wrapper = new AndroidJavaClass("com.bugsee.unity.UnityWrapper")) { - _lifecycleProxy = new LifecycleListenerProxy(); - _bugsee.CallStatic("setLifecycleEventsListener", _lifecycleProxy); + wrapper.CallStatic("attachLifecycleListener", _lifecycleProxy); } } diff --git a/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java index a603725..857369b 100644 --- a/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java +++ b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/AndroidWrapperTests.java @@ -1,12 +1,19 @@ package com.bugsee.unity; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import com.bugsee.library.contracts.lifecycle.LifecycleEventListener; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.Executor; import java.util.stream.Stream; import org.junit.Test; @@ -31,6 +38,176 @@ public void manifestRegistersTheProviderBeforeLaunch() throws Exception { assertTrue(manifest.contains("android:initOrder=\"200\"")); } + @Test + public void wrapperReportsItsRealIdentityBeforeAnyManagedCode() { + UnityWrapper wrapper = new UnityWrapper(); + assertEquals("unity", wrapper.getWrapperType()); + assertEquals(UnityWrapperIdentity.VERSION, wrapper.getWrapperVersion()); + assertNotEquals("unknown", wrapper.getWrapperVersion()); + // The contract says null for "no build"; the literal "unknown" would be reported as one. + String build = UnityWrapperIdentity.BUILD; + if (build == null || build.length() == 0) { + assertNull(wrapper.getWrapperBuild()); + } else { + assertEquals(build, wrapper.getWrapperBuild()); + } + } + + private static final class Recorder implements LifecycleEventListener { + final ArrayList seen = new ArrayList(); + + @Override + public void onEvent(String eventType, Object data) { + seen.add(new Object[] { eventType, data }); + } + + String names() { + StringBuilder out = new StringBuilder(); + for (Object[] event : seen) out.append(event[0]).append(' '); + return out.toString().trim(); + } + } + + // There is no Looper on the JVM: run the replay inline, or hold it to interleave events with it. + private static final Executor INLINE = new Executor() { + @Override + public void execute(Runnable task) { + task.run(); + } + }; + + private static void freshWrapperState() { + UnityWrapper.resetLifecycleForTest(); + UnityWrapper.replayExecutor = INLINE; + } + + @Test + public void earlyLifecycleEventsAreReplayedInOrderWithTheirDataThenForwardedExactlyOnce() { + freshWrapperState(); + UnityWrapper wrapper = new UnityWrapper(); + HashMap data = new HashMap(); + data.put("reportId", "r1"); + wrapper.onLifecycleEvent("com.bugsee.lifecycle.Launching", null); + wrapper.onLifecycleEvent("com.bugsee.lifecycle.SomeFutureEvent", data); + + Recorder listener = new Recorder(); + UnityWrapper.attachLifecycleListener(listener); + + // Replayed in order and with the ORIGINAL data object (not a string rendering of it). + assertEquals(2, listener.seen.size()); + assertEquals("com.bugsee.lifecycle.Launching", listener.seen.get(0)[0]); + assertEquals("com.bugsee.lifecycle.SomeFutureEvent", listener.seen.get(1)[0]); + assertSame(data, listener.seen.get(1)[1]); + + // After attaching, later events go straight through, once each. + wrapper.onLifecycleEvent("com.bugsee.lifecycle.Launched", null); + assertEquals(3, listener.seen.size()); + assertEquals("com.bugsee.lifecycle.Launched", listener.seen.get(2)[0]); + + // Attaching again must not replay anything a second time. + UnityWrapper.attachLifecycleListener(listener); + assertEquals(3, listener.seen.size()); + freshWrapperState(); + } + + @Test + public void eventsThatArriveDuringTheReplayAreDeliveredAfterItInOrder() { + freshWrapperState(); + final ArrayList held = new ArrayList(); + UnityWrapper.replayExecutor = new Executor() { + @Override + public void execute(Runnable task) { + held.add(task); + } + }; + UnityWrapper wrapper = new UnityWrapper(); + wrapper.onLifecycleEvent("early1", null); + wrapper.onLifecycleEvent("early2", null); + + Recorder listener = new Recorder(); + UnityWrapper.attachLifecycleListener(listener); + assertEquals("the replay has not run yet", 0, listener.seen.size()); + + // Live events while the replay is pending must queue behind it, not overtake it or be lost. + wrapper.onLifecycleEvent("live1", null); + wrapper.onLifecycleEvent("live2", null); + assertEquals(0, listener.seen.size()); + + held.get(0).run(); + assertEquals("early1 early2 live1 live2", listener.names()); + + wrapper.onLifecycleEvent("live3", null); + assertEquals("early1 early2 live1 live2 live3", listener.names()); + freshWrapperState(); + } + + @Test + public void aListenerThatCallsBackIntoTheWrapperDuringTheReplayDoesNotDeadlockOrReorder() { + freshWrapperState(); + final UnityWrapper wrapper = new UnityWrapper(); + wrapper.onLifecycleEvent("early1", null); + wrapper.onLifecycleEvent("early2", null); + final Recorder inner = new Recorder(); + LifecycleEventListener reentrant = new LifecycleEventListener() { + @Override + public void onEvent(String eventType, Object data) { + inner.onEvent(eventType, data); + if (eventType.equals("early1")) wrapper.onLifecycleEvent("raised-by-handler", null); + } + }; + UnityWrapper.attachLifecycleListener(reentrant); + assertEquals("early1 early2 raised-by-handler", inner.names()); + freshWrapperState(); + } + + @Test + public void aThrowingListenerDoesNotLoseTheEventsAfterIt() { + freshWrapperState(); + UnityWrapper wrapper = new UnityWrapper(); + wrapper.onLifecycleEvent("early1", null); + wrapper.onLifecycleEvent("early2", null); + final Recorder seen = new Recorder(); + UnityWrapper.attachLifecycleListener(new LifecycleEventListener() { + @Override + public void onEvent(String eventType, Object data) { + seen.onEvent(eventType, data); + if (eventType.equals("early1")) throw new IllegalStateException("handler bug"); + } + }); + assertEquals("early1 early2", seen.names()); + wrapper.onLifecycleEvent("live", null); + assertEquals("early1 early2 live", seen.names()); + freshWrapperState(); + } + + @Test + public void whenTheEarlyBufferIsFullTheEarliestEventsAreKept() { + freshWrapperState(); + UnityWrapper wrapper = new UnityWrapper(); + for (int i = 0; i < 130; i++) { + wrapper.onLifecycleEvent("e" + i, null); + } + + Recorder listener = new Recorder(); + UnityWrapper.attachLifecycleListener(listener); + assertEquals(128, listener.seen.size()); + assertEquals("e0", listener.seen.get(0)[0]); + assertEquals("e127", listener.seen.get(127)[0]); + freshWrapperState(); + } + + @Test + public void eventsWithoutANameAreIgnoredAndNullListenersRejected() { + freshWrapperState(); + UnityWrapper wrapper = new UnityWrapper(); + wrapper.onLifecycleEvent(null, null); + UnityWrapper.attachLifecycleListener(null); + Recorder listener = new Recorder(); + UnityWrapper.attachLifecycleListener(listener); + assertEquals(0, listener.seen.size()); + freshWrapperState(); + } + @Test public void compiledWrapperDeclaresTheSdkContract() throws Exception { String pool = classPool("com/bugsee/unity/UnityWrapper.class"); diff --git a/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs b/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs new file mode 100644 index 0000000..ce09ef7 --- /dev/null +++ b/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs @@ -0,0 +1,70 @@ +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The wrapper version is written in several places. They are generated from one file; + /// this fails when one is edited by hand and the others are not. + /// + [TestFixture] + public class WrapperIdentityDriftTests + { + static string RepoFile(string relative) + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "package.json"))) dir = dir.Parent; + Assert.That(dir, Is.Not.Null, "repo root (package.json) not found"); + return Path.Combine(dir.FullName, relative); + } + + static string Capture(string file, string pattern) + { + var match = Regex.Match(File.ReadAllText(RepoFile(file)), pattern); + Assert.That(match.Success, Is.True, file + " does not match " + pattern); + return match.Groups[1].Value; + } + + [Test] + public void Package_version_is_the_same_everywhere() + { + var source = Capture("Tools~/versions.env", "BUGSEE_PACKAGE_VERSION=\"([^\"]+)\""); + Assert.That(Capture("package.json", "\"version\":\\s*\"([^\"]+)\""), Is.EqualTo(source)); + Assert.That(Capture("Runtime/BugseePackageVersion.cs", "Version = \"([^\"]+)\""), Is.EqualTo(source)); + Assert.That(Capture("Plugins/Android/UnityWrapperIdentity.java", "VERSION = \"([^\"]+)\""), Is.EqualTo(source), + "early Android registration would report a different wrapper version than C#"); + // Both files are written by one run of the generator, so the build must agree too. + Assert.That(Capture("Plugins/Android/UnityWrapperIdentity.java", "BUILD = \"([^\"]*)\""), + Is.EqualTo(Capture("Runtime/BugseePackageVersion.cs", "Build = \"([^\"]*)\""))); + } + + // Lifecycle events must reach C# through one path. Registering the managed listener with + // Bugsee.setLifecycleEventsListener as well would deliver events twice, or lose some in the gap. + [Test] + public void Lifecycle_events_reach_managed_code_only_through_the_wrapper() + { + var bridge = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(bridge, Does.Contain("attachLifecycleListener")); + Assert.That(bridge, Does.Not.Contain("CallStatic(\"setLifecycleEventsListener\""), "the call, not a mention in a comment"); + var rules = File.ReadAllText(RepoFile("Plugins/Android/bugsee-unity-wrapper.proguard")); + Assert.That(rules, Does.Contain("attachLifecycleListener")); + } + + [Test] + public void Native_sdk_pins_are_the_same_everywhere() + { + var android = Capture("Tools~/versions.env", "ANDROID_SDK_VERSION=\"([^\"]+)\""); + Assert.That(Capture("Runtime/BugseePackageVersion.cs", "AndroidSdkVersion = \"([^\"]+)\""), Is.EqualTo(android)); + Assert.That(Capture("Editor/BugseeAndroidDependencies.xml", "bugsee-android:([^\"]+)\""), Is.EqualTo(android)); + Assert.That(Capture("Editor/BugseeAndroidDependencies.xml", "bugsee-android-ndk:([^\"]+)\""), Is.EqualTo(android), + "the NDK artifact must track the core version"); + Assert.That(Capture("Editor/BugseeAndroidGradleSetup.cs", "SdkVersion = \"([^\"]+)\""), Is.EqualTo(android)); + Assert.That(Capture("Tests~/Android/wrapper/build.gradle", "bugsee-android:([^\"]+)\""), Is.EqualTo(android)); + + var ios = Capture("Tools~/versions.env", "IOS_SDK_VERSION=\"([^\"]+)\""); + Assert.That(Capture("Runtime/BugseePackageVersion.cs", "IosSdkVersion = \"([^\"]+)\""), Is.EqualTo(ios)); + Assert.That(Capture("Tests~/iOS/run-tests.sh", "Bugsee-([0-9][^.]*\\.[0-9.]+(?:-[a-z0-9]+)?)\\.zip\""), Is.EqualTo(ios)); + } + } +} diff --git a/Tools~/scripts/update-native-sdks.sh b/Tools~/scripts/update-native-sdks.sh index 5c54d53..0439434 100755 --- a/Tools~/scripts/update-native-sdks.sh +++ b/Tools~/scripts/update-native-sdks.sh @@ -56,7 +56,9 @@ echo "Updated ${ANDROID_DEPS_XML}" # --- iOS SPM pin (github.com/bugsee/spm) --- if command -v git >/dev/null 2>&1; then - REMOTE_SHA="$(git ls-remote "${IOS_SDK_REPO}" "refs/tags/${IOS_SDK_VERSION}" 2>/dev/null | awk '{print $1}')" + # Offline or a failing ls-remote must not abort here (set -e/pipefail): the Android identity file below + # would then never be written and Java and C# would disagree on the version. + REMOTE_SHA="$(git ls-remote "${IOS_SDK_REPO}" "refs/tags/${IOS_SDK_VERSION}" 2>/dev/null | awk '{print $1}' || true)" if [[ -z "${REMOTE_SHA}" ]]; then echo "WARNING: could not resolve ${IOS_SDK_REPO} tag ${IOS_SDK_VERSION}." >&2 elif [[ -n "${IOS_SDK_COMMIT:-}" && "${REMOTE_SHA}" != "${IOS_SDK_COMMIT}" && "${REMOTE_SHA}" != *"${IOS_SDK_COMMIT}"* ]]; then @@ -67,6 +69,24 @@ if command -v git >/dev/null 2>&1; then fi fi +IDENTITY_JAVA="${ROOT_DIR}/Plugins/Android/UnityWrapperIdentity.java" +cat > "${IDENTITY_JAVA}" </dev/null || echo dev)"; + + private UnityWrapperIdentity() {} +} +JAVA_EOF +echo "Updated ${IDENTITY_JAVA}" + # Keep package.json version in sync when present PACKAGE_JSON="${ROOT_DIR}/package.json" if command -v python3 >/dev/null 2>&1 && [[ -f "${PACKAGE_JSON}" ]]; then From bc44948abf4532bd461b4645dc5ceb638ae26b0b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:24:43 +0500 Subject: [PATCH 082/157] Generate dSYMs for every iOS build configuration on both Unity targets. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-process added the SPM references and raised the deployment target but changed no other build setting. Debug configurations default to DEBUG_INFORMATION_FORMAT=dwarf, which leaves no dSYM to upload. Set DEBUG_INFORMATION_FORMAT=dwarf-with-dsym and GCC_GENERATE_DEBUGGING_SYMBOLS=YES on every build configuration of both Unity-iPhone and UnityFramework (the framework that holds the IL2CPP code), before the project is written, on both the remote- and local-SPM paths. Release normally already has dwarf-with-dsym, so nothing changes there. Opt-out: dsymutil on a large UnityFramework adds to every Debug build, simulator included. To keep the project's own setting, tick Bugsee > iOS > Keep Xcode Debug Info Format (skip dSYM force) (an EditorPrefs toggle, since a Finder-launched Editor never sees a shell export), or set BUGSEE_NO_DSYM_FORCE (CI). Both are documented in Documentation~/index.md. Simulator builds are not excluded: the Bugsee Run Script uploads for every configuration once fix/ios-symbols-upload-debug-too lands, simulator included, and a simulator crash without a dSYM stays unsymbolicated. Why it matters: Bugsee's own upload agent (Tools/BugseeAgent, shipped in the xcframework) skips the dSYM upload unless the format is dwarf-with-dsym, and the Run Script this package injects (BugseeSymbolUpload, upload-symbols.sh) can only upload a Debug dSYM that exists. On this branch alone that script still exits for non-Release unless BUGSEE_UPLOAD_ON_DEBUG=1; fix/ios-symbols-upload-debug-too removes that gate. A source test pins both opt-outs (fails without the EditorPrefs check). Not verified here: this is Editor code that only compiles inside Unity, and no iOS export was built for it yet. Unverified, from reading the API: that target-level settings take effect over project-level ones (they do in Xcode), and that newer Unity versions compile IL2CPP code in the UnityFramework target. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 1 + Editor/BugseeIosSpmPostProcess.cs | 54 +++++++++++++++++++ Tests~/WrapperPolicy/IosDsymSourceTests.cs | 63 ++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 Tests~/WrapperPolicy/IosDsymSourceTests.cs diff --git a/Documentation~/index.md b/Documentation~/index.md index a91922d..1393a28 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -115,6 +115,7 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn 2. Enable source mapping / Script Debugging as needed for LineNumberMappings generation 3. Archive in Xcode (dSYM + UUID for `UnityFramework` are available at archive time) 4. Ensure `BUGSEE_APP_TOKEN` is visible to the Xcode Run Script phase +5. The iOS export sets `DEBUG_INFORMATION_FORMAT=dwarf-with-dsym` on every configuration of Unity-iPhone and UnityFramework, so Debug builds have a dSYM too. `dsymutil` on UnityFramework adds time to each Debug build; to keep Unity's own setting, tick **Bugsee → iOS → Keep Xcode Debug Info Format (skip dSYM force)** or set `BUGSEE_NO_DSYM_FORCE` in the Editor's environment (CI) ### Script Debugging vs dSYM diff --git a/Editor/BugseeIosSpmPostProcess.cs b/Editor/BugseeIosSpmPostProcess.cs index d8fa6e4..0370393 100644 --- a/Editor/BugseeIosSpmPostProcess.cs +++ b/Editor/BugseeIosSpmPostProcess.cs @@ -30,6 +30,25 @@ public static class BugseeIosSpmPostProcess const string LocalPackageFolderName = "Bugsee"; const string LocalPackageRelativePath = "Native~/ios/Bugsee"; + const string PrefNoDsymForce = "Bugsee.Ios.NoDsymForce"; + const string MenuNoDsymForce = "Bugsee/iOS/Keep Xcode Debug Info Format (skip dSYM force)"; + + [MenuItem(MenuNoDsymForce)] + static void MenuToggleNoDsymForce() + { + bool next = !EditorPrefs.GetBool(PrefNoDsymForce, false); + EditorPrefs.SetBool(PrefNoDsymForce, next); + Debug.Log( + "[Bugsee] Skip dSYM force = " + next + + ". Applies to the next iOS export; Debug then keeps Unity's plain DWARF and has no dSYM to upload."); + } + + [MenuItem(MenuNoDsymForce, true)] + static bool MenuToggleNoDsymForceValidate() + { + Menu.SetChecked(MenuNoDsymForce, EditorPrefs.GetBool(PrefNoDsymForce, false)); + return true; + } [PostProcessBuild(999)] public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject) @@ -47,6 +66,8 @@ public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProj string frameworkTargetGuid = project.GetUnityFrameworkTargetGuid(); EnsureMinIosDeploymentTarget(project, mainTargetGuid); EnsureMinIosDeploymentTarget(project, frameworkTargetGuid); + EnsureDsymGeneration(project, mainTargetGuid); + EnsureDsymGeneration(project, frameworkTargetGuid); if (UseRemoteSpm) { @@ -82,6 +103,39 @@ public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProj Debug.Log($"[Bugsee] Linked local SPM package at {destPackageDir}"); } + /// + /// Debug configurations default to plain DWARF, which leaves no dSYM to upload. + /// Symbolication needs one for every configuration, on both the app and UnityFramework. + /// + static void EnsureDsymGeneration(PBXProject project, string targetGuid) + { + if (string.IsNullOrEmpty(targetGuid)) + { + return; + } + + // Opt-out for teams that keep Debug on plain DWARF to save build time (dsymutil on a large + // UnityFramework is slow). The menu toggle covers a Finder-launched Editor, which never + // sees a shell export; the env var covers CI. + if (System.Environment.GetEnvironmentVariable("BUGSEE_NO_DSYM_FORCE") != null || + EditorPrefs.GetBool(PrefNoDsymForce, false)) + { + return; + } + + foreach (string configName in project.BuildConfigNames()) + { + string configGuid = project.BuildConfigByName(targetGuid, configName); + if (string.IsNullOrEmpty(configGuid)) + { + continue; + } + + project.SetBuildPropertyForConfig(configGuid, "DEBUG_INFORMATION_FORMAT", "dwarf-with-dsym"); + project.SetBuildPropertyForConfig(configGuid, "GCC_GENERATE_DEBUGGING_SYMBOLS", "YES"); + } + } + static void EnsureMinIosDeploymentTarget(PBXProject project, string targetGuid) { if (string.IsNullOrEmpty(targetGuid)) diff --git a/Tests~/WrapperPolicy/IosDsymSourceTests.cs b/Tests~/WrapperPolicy/IosDsymSourceTests.cs new file mode 100644 index 0000000..32df0a1 --- /dev/null +++ b/Tests~/WrapperPolicy/IosDsymSourceTests.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IosDsymSourceTests + { + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return Path.Combine(dir, relative); + var parent = Directory.GetParent(dir); + dir = parent?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string ExtractMethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing method: " + 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 method body: " + signature); + return null; + } + + static string PostProcessSource() => + File.ReadAllText(RepoFile("Editor/BugseeIosSpmPostProcess.cs")); + + [Test] + public void Dsym_force_honours_env_var_and_editor_pref_opt_outs() + { + var body = ExtractMethodBody(PostProcessSource(), "static void EnsureDsymGeneration("); + + Assert.That(body, Does.Contain("GetEnvironmentVariable(\"BUGSEE_NO_DSYM_FORCE\")")); + Assert.That(body, Does.Contain("EditorPrefs.GetBool(PrefNoDsymForce, false)"), + "A Finder-launched Editor never sees a shell export; the menu toggle must also skip the force."); + } + + [Test] + public void Dsym_opt_out_menu_toggles_the_same_pref() + { + var source = PostProcessSource(); + var body = ExtractMethodBody(source, "static void MenuToggleNoDsymForce("); + + Assert.That(source, Does.Contain("[MenuItem(MenuNoDsymForce)]")); + Assert.That(body, Does.Contain("EditorPrefs.SetBool(PrefNoDsymForce, next)")); + } + } +} From 4fb2ab997787fd48fee4b8b77b469214c8c7dda5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 15:13:13 +0500 Subject: [PATCH 083/157] iOS: run the symbol-upload Run Script through sh so it cannot fail the build. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Run Script phase this package injects ('Bugsee Upload Symbols') invoked BugseeScripts/upload-symbols.sh as a bare command. The script is written from C# (File.WriteAllText), so it has no executable bit, and Xcode failed every iOS build with 'upload-symbols.sh: Permission denied' (observed on the first simulator build of the sample, Debug configuration; the script file is -rw-r--r--). It failed before the script's own gates ('skip for non-Release', 'skip without a token', 'never fail the archive') could run, so a project using this package could not be built at all. The phase now runs the file through /bin/sh, and a non-zero exit only prints a message: a symbol-upload problem must never fail an app build. Checked by running the phase text with /bin/sh in four situations (Debug skip, Release without a token, Release with a token but no CLI, script file missing): all exit 0. An "Append" export keeps the phase an earlier package version added, and PBXProject.AddShellScriptBuildPhase reuses a phase only when its script is identical, so it would add the new phase behind the old failing one. The injector now first sets the script of any existing 'Bugsee Upload Symbols' phase to the current one (XcodeShellScriptPhase.RewriteScript, a text edit of the serialized project), and Add then reuses that phase. Checked with Unity 2021.3's own Xcode API (UnityEditor.iOS.Extensions.Xcode.dll) on a pbxproj exported by the earlier package version: without the rewrite the target ends up with two phases (old bare path first); with it, one phase, and the only change to the file is that phase's shellScript line. An export that already has the current phase is left byte-identical. Tests: source tests pin the invocation, the failure tolerance, and that the rewrite runs before Add; unit tests cover the rewrite on phases copied from a real export (old phase rewritten, other phases untouched, current or absent phase unchanged). Verified by exporting the sample for the iOS Simulator and building it in Xcode (Debug): the phase no longer fails and the build succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeSymbolUpload.cs | 16 +-- .../WrapperPolicy/XcodeShellScriptPhase.cs | 74 +++++++++++++ .../XcodeShellScriptPhase.cs.meta | 11 ++ .../IosSymbolUploadPhaseTests.cs | 101 ++++++++++++++++++ 4 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 Runtime/WrapperPolicy/XcodeShellScriptPhase.cs create mode 100644 Runtime/WrapperPolicy/XcodeShellScriptPhase.cs.meta create mode 100644 Tests~/WrapperPolicy/IosSymbolUploadPhaseTests.cs diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 4ef340c..5396458 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using Bugsee.WrapperPolicy; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; @@ -279,18 +280,21 @@ static void InjectArchiveUploadScript(string pathToBuiltProject) const string phaseName = "Bugsee Upload Symbols"; // Write a dedicated shell script and add a run-script phase. - // Duplicate phases on re-export are annoying but harmless (token-gated). var scriptsDir = Path.Combine(pathToBuiltProject, "BugseeScripts"); Directory.CreateDirectory(scriptsDir); var scriptPath = Path.Combine(scriptsDir, "upload-symbols.sh"); File.WriteAllText(scriptPath, BuildArchiveScript(), new UTF8Encoding(false)); - project.AddShellScriptBuildPhase( - target, - phaseName, - "/bin/sh", - "\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\"\n"); + // Run it through sh: a file written from C# has no executable bit, so invoking the path directly + // fails the whole build with "Permission denied" (Debug and Release alike, before the script's own + // "never fail the build" handling can run). The "||" keeps a failing upload from failing the build. + const string shellScript = + "/bin/sh \"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\" || echo \"Bugsee: symbol upload script failed (the build continues).\"\n"; + // An "Append" export keeps the phase an earlier package version added (the bare-path one fails), and + // AddShellScriptBuildPhase reuses a phase only when its script is identical: update it in place first. + project.ReadFromString(XcodeShellScriptPhase.RewriteScript(project.WriteToString(), phaseName, shellScript)); + project.AddShellScriptBuildPhase(target, phaseName, "/bin/sh", shellScript); project.WriteToFile(projectPath); Debug.Log( "[Bugsee] Injected Xcode Run Script 'Bugsee Upload Symbols' for archive-time " + diff --git a/Runtime/WrapperPolicy/XcodeShellScriptPhase.cs b/Runtime/WrapperPolicy/XcodeShellScriptPhase.cs new file mode 100644 index 0000000..c68fa64 --- /dev/null +++ b/Runtime/WrapperPolicy/XcodeShellScriptPhase.cs @@ -0,0 +1,74 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Text edits on a serialized Xcode project (project.pbxproj) for the Run Script phase this package injects. + /// + public static class XcodeShellScriptPhase + { + const string PhaseIsa = "isa = PBXShellScriptBuildPhase;"; + static readonly Regex ShellScriptValue = new Regex("shellScript = \"(?:[^\"\\\\]|\\\\.)*\";"); + + /// + /// Sets the script of every shell script phase named to + /// . Unity's "Append" export keeps the phases of an earlier export, and + /// PBXProject.AddShellScriptBuildPhase reuses a phase only when its script is identical, so a phase from an + /// earlier package version would otherwise stay in the target, ahead of the new one. + /// + public static string RewriteScript(string pbxproj, string phaseName, string shellScript) + { + if (pbxproj == null) throw new ArgumentNullException(nameof(pbxproj)); + if (phaseName == null) throw new ArgumentNullException(nameof(phaseName)); + if (shellScript == null) throw new ArgumentNullException(nameof(shellScript)); + + string nameLine = "name = " + Quote(phaseName) + ";"; + string scriptLine = "shellScript = " + Quote(shellScript) + ";"; + var result = new StringBuilder(pbxproj.Length); + int copied = 0; + int isa = pbxproj.IndexOf(PhaseIsa, StringComparison.Ordinal); + while (isa >= 0) + { + // An object's properties end where the next object's "isa" starts: both Unity and Xcode write isa first. + int next = pbxproj.IndexOf("isa = ", isa + PhaseIsa.Length, StringComparison.Ordinal); + int end = next < 0 ? pbxproj.Length : next; + string phase = pbxproj.Substring(isa, end - isa); + if (phase.IndexOf(nameLine, StringComparison.Ordinal) >= 0) + { + Match script = ShellScriptValue.Match(phase); + if (script.Success) + { + result.Append(pbxproj, copied, isa + script.Index - copied); + result.Append(scriptLine); + copied = isa + script.Index + script.Length; + } + } + isa = next < 0 ? -1 : pbxproj.IndexOf(PhaseIsa, next, StringComparison.Ordinal); + } + result.Append(pbxproj, copied, pbxproj.Length - copied); + return result.ToString(); + } + + /// A pbxproj quoted string, escaped the way Unity's PBXProject.WriteToFile writes it. + static string Quote(string value) + { + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (char c in value) + { + switch (c) + { + case '\\': sb.Append("\\\\"); break; + case '"': sb.Append("\\\""); break; + case '\n': sb.Append("\\n"); break; + case '\t': sb.Append("\\t"); break; + default: sb.Append(c); break; + } + } + sb.Append('"'); + return sb.ToString(); + } + } +} diff --git a/Runtime/WrapperPolicy/XcodeShellScriptPhase.cs.meta b/Runtime/WrapperPolicy/XcodeShellScriptPhase.cs.meta new file mode 100644 index 0000000..2bd5bc5 --- /dev/null +++ b/Runtime/WrapperPolicy/XcodeShellScriptPhase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f91fbc763b64059b06c21debc91492b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/IosSymbolUploadPhaseTests.cs b/Tests~/WrapperPolicy/IosSymbolUploadPhaseTests.cs new file mode 100644 index 0000000..0887571 --- /dev/null +++ b/Tests~/WrapperPolicy/IosSymbolUploadPhaseTests.cs @@ -0,0 +1,101 @@ +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IosSymbolUploadPhaseTests + { + const string PhaseName = "Bugsee Upload Symbols"; + const string NewScript = + "/bin/sh \"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\" || echo \"Bugsee: symbol upload script failed (the build continues).\"\n"; + + // As Unity 2021.3 wrote them in an export of the sample: another tool's phase, then the phase of the earlier + // package version (bare path, fails with "Permission denied"). + const string OtherPhase = + "\t\tCA8A3E04DB9B195EABF139B7 /* ShellScript */ = {\n" + + "\t\t\tisa = PBXShellScriptBuildPhase;\n" + + "\t\t\tbuildActionMask = 2147483647;\n" + + "\t\t\tfiles = (\n\t\t\t);\n" + + "\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n" + + "\t\t\trunOnlyForDeploymentPostprocessing = 0;\n" + + "\t\t\tshellPath = /bin/sh;\n" + + "\t\t\tshellScript = \"diff \\\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\\\" \\\"${PODS_ROOT}/Manifest.lock\\\" > /dev/null\\nexit 1\\n\";\n" + + "\t\t\tshowEnvVarsInLog = 0;\n" + + "\t\t};\n"; + + const string OldPhase = + "\t\tF2CE4AE9812048D09DE3E73A /* ShellScript */ = {\n" + + "\t\t\tisa = PBXShellScriptBuildPhase;\n" + + "\t\t\tbuildActionMask = 2147483647;\n" + + "\t\t\tfiles = (\n\t\t\t);\n" + + "\t\t\tname = \"Bugsee Upload Symbols\";\n" + + "\t\t\trunOnlyForDeploymentPostprocessing = 0;\n" + + "\t\t\tshellPath = /bin/sh;\n" + + "\t\t\tshellScript = \"\\\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\\\"\\n\";\n" + + "\t\t};\n"; + + // The same phase as Unity writes it after adding it with NewScript to a fresh export. + const string NewScriptLine = + "shellScript = \"/bin/sh \\\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\\\" || echo \\\"Bugsee: symbol upload script failed (the build continues).\\\"\\n\";"; + + static string Section(string phases) => + "/* Begin PBXShellScriptBuildPhase section */\n" + phases + "/* End PBXShellScriptBuildPhase section */\n\n" + + "/* Begin PBXSourcesBuildPhase section */\n" + + "\t\t1D60588E0D05DD3D006BFB54 /* Sources */ = {\n" + + "\t\t\tisa = PBXSourcesBuildPhase;\n" + + "\t\t};\n"; + + static string RepoFile(string relative) + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "package.json"))) dir = dir.Parent; + Assert.That(dir, Is.Not.Null, "repo root (package.json) not found"); + return Path.Combine(dir.FullName, relative); + } + + // The script is written from C# and so has no executable bit. Run directly, Xcode fails every iOS + // build with "Permission denied" - observed on the first simulator build of the sample. + [Test] + public void The_run_script_phase_invokes_the_script_through_sh_and_cannot_fail_the_build() + { + var source = File.ReadAllText(RepoFile("Editor/BugseeSymbolUpload.cs")); + Assert.That(source, Does.Contain("/bin/sh \\\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\\\" ||")); + Assert.That(source, Does.Not.Contain("\"\\\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\\\"\\n\""), + "the bare path needs an executable bit the written file does not have"); + } + + // Unity's "Append" export keeps the earlier phase; AddShellScriptBuildPhase would add a second one behind it. + [Test] + public void The_injector_updates_an_existing_phase_before_adding_one() + { + var source = File.ReadAllText(RepoFile("Editor/BugseeSymbolUpload.cs")); + int rewrite = source.IndexOf("XcodeShellScriptPhase.RewriteScript(project.WriteToString(), phaseName, shellScript)"); + int add = source.IndexOf("project.AddShellScriptBuildPhase(target, phaseName, \"/bin/sh\", shellScript)"); + Assert.That(rewrite, Is.GreaterThan(0)); + Assert.That(add, Is.GreaterThan(rewrite)); + } + + [Test] + public void The_phase_of_an_earlier_version_gets_the_new_script_and_nothing_else_changes() + { + string before = Section(OtherPhase + OldPhase); + string after = XcodeShellScriptPhase.RewriteScript(before, PhaseName, NewScript); + + string expected = Section(OtherPhase + OldPhase.Replace( + "shellScript = \"\\\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\\\"\\n\";", NewScriptLine)); + Assert.That(after, Is.Not.EqualTo(before)); + Assert.That(after, Is.EqualTo(expected)); + } + + [Test] + public void A_current_phase_or_a_project_without_one_is_left_unchanged() + { + string current = Section(OtherPhase + OldPhase.Replace( + "shellScript = \"\\\"${PROJECT_DIR}/BugseeScripts/upload-symbols.sh\\\"\\n\";", NewScriptLine)); + Assert.That(XcodeShellScriptPhase.RewriteScript(current, PhaseName, NewScript), Is.EqualTo(current)); + + string none = Section(OtherPhase); + Assert.That(XcodeShellScriptPhase.RewriteScript(none, PhaseName, NewScript), Is.EqualTo(none)); + } + } +} From 7db374a70b4230b89fc35ef6ec33e774e534e52f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:25:11 +0500 Subject: [PATCH 084/157] iOS: lock the managed-upload cancel state; fix the no-SDK stub typedef. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gManagedReportUploadFence and gCancelledManagedReportUploadIds are written on the Unity thread and read on the SDK's completion queue (createReportWithCompletion: is documented as not delivered on main) with no synchronization; a mutable set read while another thread mutates it is undefined behaviour. One @synchronized lock now guards the fence and the set: every use is inside it (checked), and the lock is never held across an SDK or managed call, so it cannot deadlock. The cancel set is created with [NSMutableSet new] instead of [NSMutableSet set]: the plugin's .meta sets no -fobjc-arc flag, so whether Unity compiles it with ARC is not established here (only the unit tests use ARC); under manual reference counting an autoreleased object kept in a global is freed when the pool drains. new is correct under both. The no-SDK stub (compiled when the Bugsee headers are absent) declared BugseeManagedReportCreateCallback with one parameter but called it with two (base: "too many arguments to function call, expected 1, have 2"), so that configuration did not compile. It now matches the real declaration; both translation units syntax-check in stub mode on this branch. Managed side of the same handshake: Stop and Delete deleted the upload snapshot files before bumping the native fence, so an upload applying on the SDK queue still read as active during that loop and could upload a report stripped of its file attachments. CancelManagedReportUpload now invalidates the native fence first (as Android does), so the next active check fails; a source test pins the order. Known and left alone (older code, outside this change): the checks in _bugsee_upload_managed_report are atomic but not check-then-act (a cancel between the check and the callback/upload still goes through), and the managed completion handler reads/writes _inFlightUploadToken on the SDK's queue. Both are narrow races that need their own change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Plugins/iOS/BugseeUnityBridge.mm | 32 ++++++++++++-------- Runtime/Platform/IOS/IOSBridge.cs | 4 ++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 11 +++++++ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 13bdb11..30d1b66 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -46,18 +46,22 @@ static void BugseeRunOnMain(dispatch_block_t block) typedef void (*BugseeManagedReportCreateCallback)(int succeeded, uint64_t uploadToken); +// Written on the Unity thread, read on the SDK's completion queue: guard with one lock. +static NSObject *gManagedReportUploadLock = [NSObject new]; static uint64_t gManagedReportUploadFence; static NSMutableSet *gCancelledManagedReportUploadIds; static BOOL BugseeManagedReportUploadStillActive(uint64_t uploadId, uint64_t uploadFence) { - if (uploadId == 0 || uploadFence != gManagedReportUploadFence) { - return NO; - } - if (!gCancelledManagedReportUploadIds) { - return YES; + @synchronized (gManagedReportUploadLock) { + if (uploadId == 0 || uploadFence != gManagedReportUploadFence) { + return NO; + } + if (!gCancelledManagedReportUploadIds) { + return YES; + } + return ![gCancelledManagedReportUploadIds containsObject:@(uploadId)]; } - return ![gCancelledManagedReportUploadIds containsObject:@(uploadId)]; } static void BugseeBridgeSetAttachmentFileNameIfNeeded(id attachment, NSString *displayName, NSString *fileName) @@ -535,16 +539,20 @@ void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollected void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { - if (!gCancelledManagedReportUploadIds) { - gCancelledManagedReportUploadIds = [NSMutableSet set]; + @synchronized (gManagedReportUploadLock) { + if (!gCancelledManagedReportUploadIds) { + gCancelledManagedReportUploadIds = [NSMutableSet new]; + } + [gCancelledManagedReportUploadIds addObject:@(uploadId)]; } - [gCancelledManagedReportUploadIds addObject:@(uploadId)]; } void _bugsee_invalidate_managed_report_uploads(void) { - gManagedReportUploadFence++; - [gCancelledManagedReportUploadIds removeAllObjects]; + @synchronized (gManagedReportUploadLock) { + gManagedReportUploadFence++; + [gCancelledManagedReportUploadIds removeAllObjects]; + } } void _bugsee_upload_managed_report(const char *reportJson, @@ -599,7 +607,7 @@ void _bugsee_upload_managed_report(const char *reportJson, #else // !BUGSEE_IOS_SDK -typedef void (*BugseeManagedReportCreateCallback)(int succeeded); +typedef void (*BugseeManagedReportCreateCallback)(int succeeded, uint64_t uploadToken); typedef int (*BugseeDeleteCollectedDataShouldRunFn)(int capturedGeneration); extern "C" { diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 32a09ed..932f8cd 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -203,8 +203,10 @@ void CancelManagedReportUpload() _reportUploadGeneration++; _managedReportUploadFence++; _inFlightUploadToken = 0; - ReleaseAllUploadSnapshotReports(); + // Native fence first: an upload applying on the SDK queue must already read as cancelled when its + // snapshot files go away, or a report stripped of those attachments is uploaded. _bugsee_invalidate_managed_report_uploads(); + ReleaseAllUploadSnapshotReports(); } void ReleaseAllUploadSnapshotReports() diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 6bdaa0e..643af9b 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -139,6 +139,17 @@ public void Ios_upload_managed_report_uses_beta5_create_and_apply_once() Assert.That(bridge, Does.Not.Contain("[Bugsee createReport]")); } + [Test] + public void Ios_cancel_invalidates_native_uploads_before_deleting_snapshot_files() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string cancel = ExtractMethodBody(ios, "void CancelManagedReportUpload()"); + int invalidate = cancel.IndexOf("_bugsee_invalidate_managed_report_uploads();", StringComparison.Ordinal); + int release = cancel.IndexOf("ReleaseAllUploadSnapshotReports();", StringComparison.Ordinal); + Assert.That(invalidate, Is.GreaterThanOrEqualTo(0)); + Assert.That(release, Is.GreaterThan(invalidate)); + } + [Test] public void Ios_upload_report_clears_slot_before_native_upload() { From f6dd95f94ec89d3ecc9008cbb59f637003018b8e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:26:23 +0500 Subject: [PATCH 085/157] Sample: configurable e2e builds, crash scenarios and a device run script. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the BugseeMiniGame sample usable for the crash and symbolication matrix. Merge after #33 (fix/android-testcrash-main-looper): the `crash` and `java` scenarios call Bugsee.TestCrash(), which only kills the process once TestCrash is thrown from the main looper. Merge after #30 (fix/android-wrapper-provider-manifest): e2e-android.sh fails when UnityWrapperProvider is missing from the installed manifest, which only holds once the provider is injected there. - Editor/E2EBuild: BuildAndroid/BuildIos driven by BUGSEE_E2E_BACKEND (il2cpp|mono), BUGSEE_E2E_DEV, BUGSEE_E2E_MINIFY and BUGSEE_E2E_OUT; native debug symbols for development builds. The token and endpoint are written to a git-ignored Resources/BugseeE2E.json for the build only. It is removed in a finally that now runs BEFORE the process exits (EditorApplication.Exit inside the try skipped it on a failed build), and a copy left by a killed build is removed at the start of the next one. The file is inside the built player's resources: an app token is meant to be embedded, but the endpoint may be private. Mono builds are ARMv7 only (Unity 2021.3 has no Mono ARM64). The endpoint's '/v2' is appended for iOS and stripped for Android. A bare output file name works. The backend, architecture and minify settings are written to the tracked ProjectSettings.asset, so a build leaves the tree modified. - Scenarios (intent extra bugsee_e2e): native (SIGSEGV), abort, fatal, java (uncaught on the main looper), hang (blocks the Android UI thread: the SDK's hang detection watches the main looper, so sleeping on Unity's own thread, as the first version did, is never seen as a hang) and none (arm nothing: used for the recovery launch, which used to fall into the three-exception 'sig' matrix). Scenarios arm when an endpoint override is set or the extra is given explicitly. - One shot per launch: Android restarts the activity after a crash with the SAME intent extras, so a killer scenario re-armed in every restarted process and crash-looped through the wait (and the SDK's recovery upload). The script now passes a unique run id (extra bugsee_e2e_run; iOS: BUGSEE_E2E_RUN). The app writes it to a file in persistentDataPath before the scenario fires, and a process that finds its run id already there arms nothing ('E2E run ... already ran'). A launch without a run id (a manual `am start`) behaves as before. - Tools~/scripts/e2e-android.sh runs scenarios on one device with the workbook's assertions: device identity, airplane mode is a hard failure (uploads need a network; the setting is never changed on a shared device), the SDK banner, the wrapper provider in the installed manifest, and process death judged by pid change (Android restarts the top activity after a crash). WAIT is counted from the app's 'E2E armed' log line for this run (up to READY_WAIT=90 s for it), not from `am start`, so a slow cold start (splash, world build) does not run the checks before the scenario fired; a run that never armed is a failure and is not judged further. A scenario armed twice in one run (the restart loop above) is a failure. `hang` blocks the UI thread long enough for an ANR, which may kill the process, so its liveness is printed but not judged. It is no longer `set -e`: a dead app makes pidof/adb return non-zero, which used to abort the whole run; each check records its own result. It only uninstalls the package if it installed it (from a trap), and fails loudly when the app was not running after launch. The final recovery launch (scenario none, which uploads the crash reports) is clocked the same way: UPLOAD_WAIT starts once the app logs that it armed `none` (up to READY_WAIT), and the run fails if it never does. - iOS: scenario via the BUGSEE_E2E_SCENARIO environment variable (simctl: SIMCTL_CHILD_BUGSEE_E2E_SCENARIO), with the extra scenario 'upload' (a plain Bugsee.Upload report) and a log of the SDK status at each fire. BUGSEE_E2E_VIDEO=0 turns video capture off: on an iOS Simulator the native SDK's Unity capture crashes with an NSUnknownKeyException (MTLSimTexture key 'iosurface'), so the simulator needs it off. Editor/E2EBuild also takes BUGSEE_E2E_IOS_SIM=1. Verified: bash -n; shellcheck (clean); the script against a fake adb that simulates a crash and a restart with the same run (passes), with a re-armed scenario (fails as intended) and with a recovery launch that never arms (fails; the earlier revision exited 0); policy tests unchanged. Not verified here: the Editor and sample C# only compile in Unity (it built and ran for the earlier revision of these files); the revised hang scenario and script have not been run against a device. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Samples~/BugseeMiniGame/.gitignore | 4 + .../BugseeMiniGame/Assets/Editor/E2EBuild.cs | 187 ++++++++++++------ .../BugseeHarness/BugseeSampleBootstrap.cs | 134 +++++++++++-- .../Scripts/MiniGame/MiniGameBootstrap.cs | 19 ++ Tools~/scripts/e2e-android.sh | 165 ++++++++++++++++ 5 files changed, 439 insertions(+), 70 deletions(-) create mode 100755 Tools~/scripts/e2e-android.sh diff --git a/Samples~/BugseeMiniGame/.gitignore b/Samples~/BugseeMiniGame/.gitignore index d3d0706..2700f4d 100644 --- a/Samples~/BugseeMiniGame/.gitignore +++ b/Samples~/BugseeMiniGame/.gitignore @@ -26,3 +26,7 @@ Recordings/ *.ipa .DS_Store + +# Build-time credentials injected by Editor/E2EBuild (never commit) +Assets/Resources/BugseeE2E.json +Assets/Resources/BugseeE2E.json.meta diff --git a/Samples~/BugseeMiniGame/Assets/Editor/E2EBuild.cs b/Samples~/BugseeMiniGame/Assets/Editor/E2EBuild.cs index d2e222c..7a11b1e 100644 --- a/Samples~/BugseeMiniGame/Assets/Editor/E2EBuild.cs +++ b/Samples~/BugseeMiniGame/Assets/Editor/E2EBuild.cs @@ -9,90 +9,159 @@ namespace Bugsee.Sample.Editor { /// - /// Batchmode builds for Mode B e2e: - /// Unity -batchmode -quit -projectPath … -executeMethod Bugsee.Sample.Editor.E2EBuild.BuildAndroidIl2Cpp - /// Unity -batchmode -quit -projectPath … -executeMethod Bugsee.Sample.Editor.E2EBuild.BuildIosIl2Cpp + /// Batchmode builds for end-to-end validation: + /// Unity -batchmode -quit -projectPath … -executeMethod Bugsee.Sample.Editor.E2EBuild.BuildAndroid + /// Unity -batchmode -quit -projectPath … -executeMethod Bugsee.Sample.Editor.E2EBuild.BuildIos + /// + /// Environment: + /// BUGSEE_APP_TOKEN required; baked into the build and used for symbol upload + /// BUGSEE_ENDPOINT API host (no /v2); iOS gets /v2 appended + /// BUGSEE_E2E_BACKEND il2cpp (default) | mono + /// BUGSEE_E2E_DEV 1 (default, Development build) | 0 (Release) + /// BUGSEE_E2E_MINIFY 0 (default) | 1: R8 minification on Android (produces mapping.txt) + /// BUGSEE_E2E_IOS_SIM 1: build the iOS Simulator SDK (default: the device SDK) + /// BUGSEE_E2E_OUT output path override + /// + /// Note: the backend, architectures and minify settings are written to the project's tracked + /// ProjectSettings.asset, so a build leaves the working tree modified. + /// + /// The token never lands in the scene or in git: it is written to a git-ignored + /// Resources/BugseeE2E.json for the duration of the build and removed afterwards (also removed at the + /// start of the next one if a killed build left it behind). It is still inside the built player's + /// resources, as an app token is meant to be. /// public static class E2EBuild { - const string OutApk = "Builds/E2E/BugseeMiniGame-e2e.apk"; - const string OutIos = "Builds/E2E/ios"; + const string OverrideAsset = "Assets/Resources/BugseeE2E.json"; + public static void BuildAndroid() => Build(BuildTarget.Android); + + public static void BuildIos() => Build(BuildTarget.iOS); + + // Kept for the existing Mode B scripts. public static void BuildAndroidIl2Cpp() { - Directory.CreateDirectory("Builds/E2E"); - - EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Android, BuildTarget.Android); - PlayerSettings.SetScriptingBackend(BuildTargetGroup.Android, ScriptingImplementation.IL2CPP); - PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64; - // Encourage LineNumberMappings.json generation for Mode B LNM. - EditorUserBuildSettings.development = true; - EditorUserBuildSettings.allowDebugging = true; -#if UNITY_2021_2_OR_NEWER - EditorUserBuildSettings.androidCreateSymbols = AndroidCreateSymbols.Debugging; -#endif - try - { - PlayerSettings.SetAdditionalIl2CppArgs("--emit-source-mapping"); - } - catch - { - /* older editors */ - } + Environment.SetEnvironmentVariable("BUGSEE_E2E_BACKEND", "il2cpp"); + Build(BuildTarget.Android); + } + public static void BuildIosIl2Cpp() + { + Environment.SetEnvironmentVariable("BUGSEE_E2E_BACKEND", "il2cpp"); + Build(BuildTarget.iOS); + } + + static void Build(BuildTarget target) + { var token = Environment.GetEnvironmentVariable("BUGSEE_APP_TOKEN"); if (string.IsNullOrWhiteSpace(token)) - throw new InvalidOperationException("[E2EBuild] Set BUGSEE_APP_TOKEN for Android symbol upload."); - Environment.SetEnvironmentVariable("BUGSEE_APP_TOKEN", token); + throw new InvalidOperationException("[E2EBuild] Set BUGSEE_APP_TOKEN (baked into the build, used for symbol upload)."); + + var il2cpp = !string.Equals(Environment.GetEnvironmentVariable("BUGSEE_E2E_BACKEND"), "mono", StringComparison.OrdinalIgnoreCase); + var development = Environment.GetEnvironmentVariable("BUGSEE_E2E_DEV") != "0"; + var ios = target == BuildTarget.iOS; + var group = ios ? BuildTargetGroup.iOS : BuildTargetGroup.Android; - var scenes = ResolveScenes(); - Debug.Log("[E2EBuild] Building Android IL2CPP → " + OutApk + " scenes=" + string.Join(",", scenes)); + var minify = Environment.GetEnvironmentVariable("BUGSEE_E2E_MINIFY") == "1"; + var label = (ios ? "ios" : "android") + "-" + (il2cpp ? "il2cpp" : "mono") + "-" + (development ? "dev" : "release") + + (minify && !ios ? "-min" : ""); + var output = Environment.GetEnvironmentVariable("BUGSEE_E2E_OUT"); + if (string.IsNullOrEmpty(output)) + output = ios ? "Builds/E2E/" + label : "Builds/E2E/" + label + ".apk"; + var outputDir = Path.GetDirectoryName(output.TrimEnd('/')); + // GetDirectoryName("x.apk") is "", not null, and CreateDirectory("") throws. + Directory.CreateDirectory(string.IsNullOrEmpty(outputDir) ? "." : outputDir); - var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + if (ios) { - scenes = scenes, - locationPathName = OutApk, - target = BuildTarget.Android, - options = BuildOptions.Development | BuildOptions.AllowDebugging, - }); + // The Simulator SDK needs simulator slices of the IL2CPP runtime; the device SDK does not link there. + var simulator = Environment.GetEnvironmentVariable("BUGSEE_E2E_IOS_SIM") == "1"; + PlayerSettings.iOS.sdkVersion = simulator ? iOSSdkVersion.SimulatorSDK : iOSSdkVersion.DeviceSDK; + } - ExitFromReport(report); - } + EditorUserBuildSettings.SwitchActiveBuildTarget(group, target); + PlayerSettings.SetScriptingBackend(group, il2cpp ? ScriptingImplementation.IL2CPP : ScriptingImplementation.Mono2x); + EditorUserBuildSettings.development = development; + EditorUserBuildSettings.allowDebugging = development; - public static void BuildIosIl2Cpp() - { - Directory.CreateDirectory(OutIos); + if (!ios) + { + PlayerSettings.Android.minifyRelease = minify; + PlayerSettings.Android.minifyDebug = minify; + // Unity 2021.3 builds ARM64 only with IL2CPP; a Mono build is ARMv7 (32-bit) only, so it will not + // install on a 64-bit-only device or emulator image (INSTALL_FAILED_NO_MATCHING_ABIS). + PlayerSettings.Android.targetArchitectures = il2cpp + ? AndroidArchitecture.ARM64 + : AndroidArchitecture.ARMv7; +#if UNITY_2021_2_OR_NEWER + // Native debug symbols (symbols.zip) for ELF upload; Debugging keeps full DWARF. + EditorUserBuildSettings.androidCreateSymbols = + development ? AndroidCreateSymbols.Debugging : AndroidCreateSymbols.Public; +#endif + } + + if (il2cpp) + { + try { PlayerSettings.SetAdditionalIl2CppArgs("--emit-source-mapping"); } + catch { /* older editors */ } + } - EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.iOS, BuildTarget.iOS); - PlayerSettings.SetScriptingBackend(BuildTargetGroup.iOS, ScriptingImplementation.IL2CPP); - EditorUserBuildSettings.development = true; - EditorUserBuildSettings.allowDebugging = true; + // A copy left by a build that was killed would otherwise take priority over everything else. + RemoveOverride(); + var endpoint = Environment.GetEnvironmentVariable("BUGSEE_ENDPOINT"); + WriteOverride(token, endpoint, ios); + BuildReport report; try { - PlayerSettings.SetAdditionalIl2CppArgs("--emit-source-mapping"); + var scenes = ResolveScenes(); + Debug.Log("[E2EBuild] Building " + label + " → " + output + " scenes=" + string.Join(",", scenes)); + + var options = development ? BuildOptions.Development | BuildOptions.AllowDebugging : BuildOptions.None; + report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = scenes, + locationPathName = output, + target = target, + options = options, + }); } - catch + finally { - /* older editors */ + RemoveOverride(); } - var token = Environment.GetEnvironmentVariable("BUGSEE_APP_TOKEN"); - if (string.IsNullOrWhiteSpace(token)) - throw new InvalidOperationException("[E2EBuild] Set BUGSEE_APP_TOKEN for iOS symbol upload."); - Environment.SetEnvironmentVariable("BUGSEE_APP_TOKEN", token); + // Exit only after the credentials are gone: EditorApplication.Exit ends the process at once, so + // calling it inside the try would skip the finally on a failed build. + ExitFromReport(report); + } - var scenes = ResolveScenes(); - Debug.Log("[E2EBuild] Building iOS IL2CPP → " + OutIos + " scenes=" + string.Join(",", scenes)); + [Serializable] + class E2EOverride + { + public string token; + public string endpoint; + } - var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + static void WriteOverride(string token, string endpoint, bool ios) + { + if (!string.IsNullOrWhiteSpace(endpoint)) { - scenes = scenes, - locationPathName = OutIos, - target = BuildTarget.iOS, - options = BuildOptions.Development | BuildOptions.AllowDebugging, - }); + endpoint = endpoint.Trim().TrimEnd('/'); + // iOS wants the API version in the endpoint; Android appends it itself, so a value that + // already ends in /v2 would be requested as .../v2/v2. + var versioned = endpoint.EndsWith("/v2", StringComparison.Ordinal); + if (ios && !versioned) endpoint += "/v2"; + else if (!ios && versioned) endpoint = endpoint.Substring(0, endpoint.Length - "/v2".Length); + } - ExitFromReport(report); + Directory.CreateDirectory(Path.GetDirectoryName(OverrideAsset)); + File.WriteAllText(OverrideAsset, JsonUtility.ToJson(new E2EOverride { token = token.Trim(), endpoint = endpoint })); + AssetDatabase.ImportAsset(OverrideAsset); + } + + static void RemoveOverride() + { + AssetDatabase.DeleteAsset(OverrideAsset); } static string[] ResolveScenes() diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 6bc733d..02c6f89 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -126,7 +126,9 @@ public void LaunchSdk() #elif UNITY_IOS && !UNITY_EDITOR var iosOptions = new IOSLaunchOptions { - CaptureVideo = true, + // The iOS Simulator crashes the SDK's Unity video capture (it reads a private "iosurface" key the + // simulator's texture lacks), so a simulator run sets BUGSEE_E2E_VIDEO=0. + CaptureVideo = Environment.GetEnvironmentVariable("BUGSEE_E2E_VIDEO") != "0", CaptureLogs = true, CaptureNetwork = true, ReportingTriggerByShake = true, @@ -149,15 +151,25 @@ public void LaunchSdk() // Auto e2e matrix when endpoint override is set (device → local appserver). // Scenarios via Android intent extra "bugsee_e2e" (default: sig). - // handled | unhandled | crash | sig | pipeline - if (!string.IsNullOrWhiteSpace(endpointOverride)) + // handled | unhandled | crash | sig | pipeline | native | abort | fatal | java | hang | upload | none + // Armed when an endpoint override is set or the extra is given explicitly. + var requestedScenario = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO"); + var launchRun = ReadE2EValue("bugsee_e2e_run", "BUGSEE_E2E_RUN"); + if (requestedScenario != null && !ConsumeE2ERun(launchRun)) + { + // Android restarts the activity after a crash with the SAME intent extras, so without this a + // killer scenario would fire again in every restarted process (a crash loop). + Debug.Log("[BugseeSample] E2E run " + launchRun + " already ran (restart after a crash): arming nothing"); + requestedScenario = "none"; + } + if (!string.IsNullOrWhiteSpace(endpointOverride) || requestedScenario != null) { - _e2eScenario = ReadAndroidE2EScenario(); + _e2eScenario = requestedScenario ?? "sig"; _e2eArmed = true; _e2ePhase = 0; _e2eTimer = 0f; _e2eRunId = DateTime.UtcNow.ToString("HHmmss"); - Debug.Log("[BugseeSample] E2E armed scenario=" + _e2eScenario + " runId=" + _e2eRunId); + Debug.Log("[BugseeSample] E2E armed scenario=" + _e2eScenario + " runId=" + _e2eRunId + " run=" + (launchRun ?? "-")); } SetStatus("Launch ok (token set)"); @@ -224,6 +236,31 @@ void Update() _pendingE2EFire = "C:crash"; } break; + // Real process-killing failures, one per run, for the crash/symbolication matrix: + // native = SIGSEGV in libunity, abort = SIGABRT, fatal = Unity fatal error, + // java = uncaught RuntimeException on the Android main thread, hang = blocked main thread. + case "native": + case "abort": + case "fatal": + case "java": + case "hang": + if (_e2ePhase == 0 && _e2eTimer >= 5f) + { + _e2ePhase = 99; + _pendingE2EFire = "K:" + _e2eScenario; + } + break; + case "upload": + // A plain manual report: tells "no report is ever sent" from "only handled exceptions are not". + if (_e2ePhase == 0 && _e2eTimer >= 5f) + { + _e2ePhase = 99; + _pendingE2EFire = "L:e2e-upload-" + _e2eRunId; + } + break; + case "none": + // Recovery launch: arm nothing, only let the SDK recover and upload what the last run left. + break; case "sig": default: if (_e2ePhase == 0 && _e2eTimer >= 5f) @@ -250,6 +287,8 @@ void Update() void PumpPendingE2EFire(string pending) { if (string.IsNullOrEmpty(pending) || pending.Length < 2) return; + // The SDK's state at the moment of the call: a report made while it is still Launching is retained, not sent. + Debug.Log("[BugseeSample] sdk status=" + Bugsee.Status + " launched=" + Bugsee.IsLaunched + " fire=" + pending); var kind = pending[0]; var marker = pending.Length > 2 ? pending.Substring(2) : pending; switch (kind) @@ -271,10 +310,55 @@ void PumpPendingE2EFire(string pending) SetStatus("E2E native TestCrash"); Bugsee.TestCrash(); break; + case 'K': + FireProcessFailure(marker); + break; + case 'L': + SetStatus("E2E manual upload " + marker); + Bugsee.Upload(marker, "uploaded by the e2e harness"); + break; } } - static string ReadAndroidE2EScenario() + void FireProcessFailure(string scenario) + { + SetStatus("E2E process failure: " + scenario); + switch (scenario) + { + case "native": + UnityEngine.Diagnostics.Utils.ForceCrash(UnityEngine.Diagnostics.ForcedCrashCategory.AccessViolation); + break; + case "abort": + UnityEngine.Diagnostics.Utils.ForceCrash(UnityEngine.Diagnostics.ForcedCrashCategory.Abort); + break; + case "fatal": + UnityEngine.Diagnostics.Utils.ForceCrash(UnityEngine.Diagnostics.ForcedCrashCategory.FatalError); + break; + case "java": + Bugsee.TestCrash(); + break; + case "hang": + // The SDK's hang/ANR detection watches the Android MAIN looper. This code runs on Unity's own + // thread (UnityMain), so sleeping here would stall the game but never be seen as a hang. + // Block the Android UI thread instead; longer than the SDK's hang threshold. That can also raise + // an ANR, and the process may then be killed: the run script does not judge liveness for hang. +#if UNITY_ANDROID && !UNITY_EDITOR + using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = player.GetStatic("currentActivity")) + { + activity.Call("runOnUiThread", new AndroidJavaRunnable(() => System.Threading.Thread.Sleep(20000))); + } +#else + System.Threading.Thread.Sleep(20000); +#endif + break; + } + } + + // An intent extra (Android) or environment variable (iOS), trimmed and lower-cased, or null when there is + // none. "bugsee_e2e" / BUGSEE_E2E_SCENARIO names the scenario; "bugsee_e2e_run" / BUGSEE_E2E_RUN is a + // unique id per launch from the run script. + static string ReadE2EValue(string androidExtra, string iosVariable) { #if UNITY_ANDROID && !UNITY_EDITOR try @@ -283,17 +367,45 @@ static string ReadAndroidE2EScenario() using (var activity = unityPlayer.GetStatic("currentActivity")) using (var intent = activity.Call("getIntent")) { - var scenario = intent?.Call("getStringExtra", "bugsee_e2e"); - if (!string.IsNullOrWhiteSpace(scenario)) - return scenario.Trim().ToLowerInvariant(); + var value = intent?.Call("getStringExtra", androidExtra); + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim().ToLowerInvariant(); } } catch (Exception ex) { - Debug.LogWarning("[BugseeSample] bugsee_e2e intent read failed: " + ex.Message); + Debug.LogWarning("[BugseeSample] " + androidExtra + " intent read failed: " + ex.Message); } #endif - return "sig"; +#if UNITY_IOS && !UNITY_EDITOR + // iOS has no intent extras: `SIMCTL_CHILD_BUGSEE_E2E_SCENARIO=native xcrun simctl launch ...` (or the + // scheme's environment variables on a device run) reaches the app as BUGSEE_E2E_SCENARIO. + var fromEnvironment = Environment.GetEnvironmentVariable(iosVariable); + if (!string.IsNullOrWhiteSpace(fromEnvironment)) + return fromEnvironment.Trim().ToLowerInvariant(); +#endif + return null; + } + + // Records the launch's run id as done BEFORE its scenario fires, in a file written synchronously so it + // survives the crash. False when this run id already ran, i.e. this process is the OS restarting the + // activity with the same extras. A launch without a run id (a manual `am start`) always fires, and is not + // protected against that restart. + static bool ConsumeE2ERun(string run) + { + if (string.IsNullOrEmpty(run)) return true; + var path = System.IO.Path.Combine(Application.persistentDataPath, "bugsee_e2e_last_run"); + try + { + if (System.IO.File.Exists(path) && System.IO.File.ReadAllText(path).Trim() == run) + return false; + System.IO.File.WriteAllText(path, run); + } + catch (Exception ex) + { + Debug.LogWarning("[BugseeSample] e2e run marker failed: " + ex.Message); + } + return true; } // Dedicated throw sites so stack frames (hence worker signatures) stay stable. diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/MiniGame/MiniGameBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/MiniGame/MiniGameBootstrap.cs index fcf246e..8296b03 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/MiniGame/MiniGameBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/MiniGame/MiniGameBootstrap.cs @@ -114,13 +114,32 @@ void BuildWorld() BugseeActionForms.Ensure(bootstrap); } + [System.Serializable] + sealed class BuildOverride + { + public string token; + public string endpoint; + } + + // Token/endpoint injected at build time by Editor/E2EBuild (git-ignored Resources file), + // so a validation build never needs credentials in the scene. + static BuildOverride LoadBuildOverride() + { + var asset = Resources.Load("BugseeE2E"); + return asset == null ? null : JsonUtility.FromJson(asset.text); + } + string ResolveAppToken() { + var injected = LoadBuildOverride(); + if (injected != null && !string.IsNullOrWhiteSpace(injected.token)) return injected.token.Trim(); return string.IsNullOrWhiteSpace(bugseeAppToken) ? "" : bugseeAppToken.Trim(); } string ResolveEndpoint() { + var injected = LoadBuildOverride(); + if (injected != null && !string.IsNullOrWhiteSpace(injected.endpoint)) return injected.endpoint.Trim(); return string.IsNullOrWhiteSpace(bugseeEndpoint) ? "" : bugseeEndpoint.Trim(); } diff --git a/Tools~/scripts/e2e-android.sh b/Tools~/scripts/e2e-android.sh new file mode 100755 index 0000000..6ee5ddb --- /dev/null +++ b/Tools~/scripts/e2e-android.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Run BugseeMiniGame e2e scenarios on one Android device/emulator and report what happened. +# +# Tools~/scripts/e2e-android.sh [...] +# +# Scenarios (see BugseeSampleBootstrap): handled unhandled pipeline sig crash native abort fatal java hang +# `crash` and `java` need the TestCrash-on-main-looper change; `hang` blocks the Android UI thread. +# +# Each launch passes a unique run id (extra bugsee_e2e_run). The app records it before the scenario fires, so +# when Android restarts the activity after a crash with the same extras, that process arms nothing instead of +# crashing again. WAIT is counted from the app's "E2E armed" log line (up to READY_WAIT for it), not from +# `am start`: a cold dev build can take a long time to reach Start(). +# +# The checks come from the wrapper workbook (Part 9): assert the device and the build before trusting a +# result, assert the SDK actually launched, and assert the process really died when a scenario is meant to +# kill it. Uploads need a network, so airplane mode is a hard failure (the device setting is never changed). +# +# Deliberately NOT `set -e`: a crashed app makes pidof/adb return non-zero, which is an expected result here, +# and each check records its own outcome in $status instead. +set -uo pipefail + +SERIAL="${1:?adb serial}"; APK="${2:?apk path}"; shift 2 +[[ $# -ge 1 ]] || { echo "give at least one scenario" >&2; exit 2; } + +ADB="${ADB:-$HOME/Library/Android/sdk/platform-tools/adb}" +PKG="${PKG:-com.bugsee.minigame}" +ACTIVITY="${ACTIVITY:-com.unity3d.player.UnityPlayerActivity}" +WAIT="${WAIT:-25}" # seconds to let a scenario run, after the app armed it (it fires 5 s after that) +READY_WAIT="${READY_WAIT:-90}" # seconds to wait for the app to arm the scenario +UPLOAD_WAIT="${UPLOAD_WAIT:-45}" +OUT="${OUT:-$(mktemp -d)}"; mkdir -p "$OUT" +a() { "$ADB" -s "$SERIAL" "$@"; } + +# Scenarios that must kill the process; `hang` may or may not (an ANR can kill it), so it is not judged; +# the rest must leave it running. +fatal_scenario() { case "$1" in crash|native|abort|fatal|java) return 0;; *) return 1;; esac; } +liveness_unjudged() { [[ "$1" == hang ]]; } + +pid_of() { a shell pidof "$PKG" 2>/dev/null | tr -d '\r' || true; } + +echo "== device" +state="$("$ADB" devices | awk -v s="$SERIAL" '$1==s{print $2}')" +[[ "$state" == "device" ]] || { echo "FAIL $SERIAL is not an attached device (state='$state')"; exit 1; } +echo " $SERIAL api=$(a shell getprop ro.build.version.sdk | tr -d '\r') release=$(a shell getprop ro.build.version.release | tr -d '\r') model=$(a shell getprop ro.product.model | tr -d '\r')" +if [[ "$(a shell settings get global airplane_mode_on | tr -d '\r')" != "0" ]]; then + echo "FAIL airplane mode is on: reports cannot upload (not changing someone else's device setting)"; exit 1 +fi + +# Only remove the package afterwards if this run installed it: on a shared device it may be someone's. +preinstalled=no +packages="$(a shell pm list packages "$PKG" 2>/dev/null | tr -d '\r' || true)" +grep -q "^package:${PKG}\$" <<<"$packages" && preinstalled=yes +installed_by_us=no +# shellcheck disable=SC2329 # invoked by the EXIT trap +cleanup() { + if [[ "$installed_by_us" == yes && "${KEEP:-0}" != 1 ]]; then + a uninstall "$PKG" >/dev/null 2>&1 && echo "uninstalled $PKG (set KEEP=1 to keep it)" + elif [[ "$preinstalled" == yes ]]; then + echo "left $PKG installed: it was on the device before this run" + fi +} +trap cleanup EXIT + +echo "== install $APK" +if ! install_out="$(a install -r "$APK" 2>&1)"; then + echo "FAIL install: $install_out"; exit 1 +fi +echo " ${install_out##*$'\n'}" +installed_by_us=yes +[[ "$preinstalled" == yes ]] && echo " note: replaced an existing $PKG (cleanup will uninstall it)" + +status=0 +# Read the output into a variable first: `pm dump | grep -q` makes adb die of SIGPIPE under pipefail. +manifest="$(a shell pm dump "$PKG" 2>/dev/null || true)" +if grep -q "UnityWrapperProvider" <<<"$manifest"; then + echo " ok wrapper provider registered in the installed manifest" +else + echo " FAIL wrapper provider missing from the installed manifest"; status=1 +fi + +# A fresh run id per launch (see the header): the scenario name alone would make `native native` skip the second. +run="" +launch() { + run="$1-$(date +%s)-$RANDOM" + a shell am start -n "$PKG/$ACTIVITY" --es bugsee_e2e "$1" --es bugsee_e2e_run "$run" >/dev/null +} + +# How many times a log on stdin shows this run's scenario armed (-c reads all input: no SIGPIPE under pipefail). +armed_count() { grep -F "E2E armed scenario=$1 " | grep -cF "run=$run"; } + +# Wait until the app logs that it armed this run's scenario (Start() ran and the SDK was launched). +wait_armed() { + local deadline=$((SECONDS + READY_WAIT)) dump + while (( SECONDS < deadline )); do + dump="$(a logcat -d 2>/dev/null || true)" + [[ "$(armed_count "$1" <<<"$dump")" -gt 0 ]] && return 0 + sleep 1 + done + return 1 +} + +for scenario in "$@"; do + scenario="$(tr '[:upper:]' '[:lower:]' <<<"$scenario")" # the app lower-cases what it logs + echo "== scenario: $scenario" + a shell am force-stop "$PKG"; a logcat -c + launch "$scenario" + log="$OUT/$scenario.log" + if ! wait_armed "$scenario"; then + a logcat -d -v time > "$log" + echo " FAIL the app did not arm '$scenario' within ${READY_WAIT}s; this scenario proves nothing (log: $log)" + status=1; continue + fi + # Android restarts the top activity after a crash, so "a process is running" proves nothing. + # Compare the pid from before the scenario fires (it fires 5 s after arming) with the pid afterwards. + pid_before="$(pid_of)" + if [[ -z "$pid_before" ]]; then + echo " FAIL the app was not running right after it armed '$scenario'; this scenario proves nothing" + status=1; continue + fi + sleep "$WAIT" + a logcat -d -v time > "$log" + armed="$(armed_count "$scenario" < "$log")" + if [[ "$armed" -gt 1 ]]; then + echo " FAIL the scenario was armed $armed times in one run: the restart after a crash fired it again"; status=1 + elif grep -qF "E2E run $run already ran" "$log"; then + echo " ok the restart after the crash got the same run and armed nothing" + fi + + if grep -q "Bugsee Android SDK" "$log"; then + echo " ok SDK launched: $(grep -m1 -o 'Bugsee Android SDK [^ ]* \[[^]]*\]' "$log")" + else + echo " FAIL no SDK banner: the SDK did not launch, so nothing below means anything"; status=1 + fi + + pid_after="$(pid_of)" + if [[ -n "$pid_before" && "$pid_before" == "$pid_after" ]]; then died=no; else died=yes; fi + if liveness_unjudged "$scenario"; then + echo " info process died=$died (not judged: an ANR from the blocked UI thread may kill it)" + elif fatal_scenario "$scenario"; then + if [[ "$died" == yes ]]; then echo " ok process died as intended" + else echo " FAIL process survived a scenario meant to kill it"; status=1; fi + else + if [[ "$died" == no ]]; then echo " ok process still running" + else echo " FAIL process died unexpectedly"; status=1; fi + fi + echo " crash markers: $(grep -cE 'FATAL EXCEPTION|Fatal signal|SIGSEGV|SIGABRT' "$log") (BugseeTrackerBluetooth SecurityException spam excluded: $(grep -E ' E/(Unity|Bugsee)' "$log" | grep -vc BugseeTrackerBluetooth) other error lines)" +done + +# Clocked like the scenarios: UPLOAD_WAIT starts once the app armed `none` (the SDK was launched), not at +# `am start`. The force-stop may cut short an upload by the restart after the last crash; this launch redoes it. +echo "== relaunch to recover and upload (${UPLOAD_WAIT}s after the app is up)" +a shell am force-stop "$PKG"; a logcat -c +launch none +if wait_armed none; then + sleep "$UPLOAD_WAIT" + a logcat -d -v time > "$OUT/recovery.log" + echo " RelaunchedAfterCrash seen: $(grep -c 'RelaunchedAfterCrash' "$OUT/recovery.log")" +else + a logcat -d -v time > "$OUT/recovery.log" + echo " FAIL the recovery launch did not arm within ${READY_WAIT}s: crash reports were not uploaded (log: $OUT/recovery.log)" + status=1 +fi + +echo "logs in $OUT" +exit $status From 6aee5259ff083658f51bea52963966ef72fe1ff0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:26:21 +0500 Subject: [PATCH 086/157] ExceptionPipeline: stop reporting Debug.LogException twice; dedupe with a thread-safe clock. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the managed exception pipeline. Duplicate reports (main thread): Debug.LogException reaches the SDK through ILogHandler.LogException (a real Exception) and again through Application.logMessageReceivedThreaded, which only has Unity's rendered text. The two paths used keys with nothing in common (.NET type + stack vs. Unity's text), so the comment saying one dedupes the other was never true: every main-thread Debug.LogException became two issues with different signatures ("InvalidOperationException" and "System.InvalidOperationException"). How the second copy is recognised (LogExceptionEcho): Unity raises the threaded callback synchronously, on the logging thread, from inside the default handler's LogException. Checked in a Unity 2021.3.45 Editor (batchmode, not a player): on the main thread and on a background thread the callback ran on the same thread while our handler was inside the inner call, and with this code each echo (inner-exception chain, blank message, background thread) was dropped. So the pipeline opens a per-thread expectation around _inner.LogException and the callback drops only an Exception log raised on that thread inside that call whose text is the expected one, once. An earlier revision used a 2-second global ring keyed on type + message; that let another thread's exception with the same text (default NullReferenceException messages are identical across throw sites) be taken for the echo and dropped. The text is still compared, so a different exception logged from inside that call (a nested log callback) is reported. Unity renders the INNERMOST exception of a chain as exactly "TypeName: message", or the bare "TypeName" for a blank message. When it expects the echo: when the first path DELIVERED the exception, or skipped it as a duplicate (a rethrow logged again, or the same exception logged twice within 0.75 s: the first call delivered it, so the echo would file it a second time from the text). Because that drops the echo, Duplicate must mean the same exception: the key (LogExceptionEcho.DuplicateKey) is type, message and stack of the whole chain (inner and aggregate exceptions) plus the source. Type + stack alone merged distinct exceptions - a per-item message thrown from one line, or any unthrown new Exception(msg) (null stack) - and with their echoes dropped they were never reported. Report returns an outcome (LogExceptionEcho.Outcome): Delivered when DispatchPayload returned true - the bridge call returned (main thread, or a sync flush that completed) or the non-blocking path queued it for the main thread (the echo would only be queued behind it); Duplicate for that dedupe; NotDelivered when Report skipped a nested call, the bridge call threw, or a sync flush (fatal) timed out. In those cases the echo is the only report left and is filed from the text. On a sync-flush timeout the queued send may still run later, so that crash can be reported twice: preferred to losing it. The nested-call guard is per thread now: a counter shared by all threads made a Debug.LogException on thread B skip Report while thread A was inside it. Wrong-thread clock: IsDuplicate read Time.realtimeSinceStartup, which throws UnityException off the main thread (not checked against every supported Unity version), and log callbacks arrive on any thread. Called from ILogHandler.LogException this threw out of Debug.LogException itself, before anything was reported or logged, so a background-thread Debug.LogException produced no report, no console line, and an exception in the caller. It uses a Stopwatch now, so that case reports, logs and no longer throws (a behaviour change worth knowing). Verified on staging (earlier revision, the ring): one pipeline-scenario throw (main thread, a single exception) makes one issue where it made two; the current revision was checked only in the Editor probe above. Not verified: a player build of this revision, the Unity 6 callback timing, and the failed-send / timeout paths at runtime. OnUnhandledException is unchanged; if Unity also logs unhandled background-thread exceptions through the threaded callback, those may still be reported twice (not checked). Tests: LogExceptionEchoTests - short and full name, a different exception, the same text after the call returned, another thread's exception with the same text while the call is in flight (fails when the state is not per thread), blank message (with the bare name, and not swallowing a different message), inner and aggregate exceptions, exact match, consume-on-match, nested calls; ShouldExpect per outcome (Duplicate expects the echo, NotDelivered does not); DuplicateKey tells apart exceptions differing only in message (unthrown, and thrown from one line), in an inner or aggregate exception, in stack or in source, and matches the same exception; and a source check that the pipeline expects the echo only per ShouldExpect, that the dedupe uses DuplicateKey and returns Duplicate (not NotDelivered), and that the RunSync result and DispatchPayload's result are used. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Internal/ExceptionPipeline.cs | 91 +++++-- Runtime/WrapperPolicy/LogExceptionEcho.cs | 142 ++++++++++ .../WrapperPolicy/LogExceptionEcho.cs.meta | 11 + Tests~/WrapperPolicy/LogExceptionEchoTests.cs | 247 ++++++++++++++++++ 4 files changed, 465 insertions(+), 26 deletions(-) create mode 100644 Runtime/WrapperPolicy/LogExceptionEcho.cs create mode 100644 Runtime/WrapperPolicy/LogExceptionEcho.cs.meta create mode 100644 Tests~/WrapperPolicy/LogExceptionEchoTests.cs diff --git a/Runtime/Internal/ExceptionPipeline.cs b/Runtime/Internal/ExceptionPipeline.cs index 3861642..e3ad0ef 100644 --- a/Runtime/Internal/ExceptionPipeline.cs +++ b/Runtime/Internal/ExceptionPipeline.cs @@ -4,6 +4,7 @@ using Bugsee.Contracts.Options; using Bugsee.Platform; using UnityEngine; +using EchoOutcome = Bugsee.WrapperPolicy.LogExceptionEcho.Outcome; namespace Bugsee.Internal { @@ -23,7 +24,6 @@ public sealed class ExceptionPipeline : ILogHandler readonly IBugseeNativeBridge _bridge; readonly ILogHandler _inner; readonly bool _treatUnityLogExceptionAsFatal; - int _reentrancy; string _lastDedupeKey; double _lastDedupeTime; @@ -131,8 +131,21 @@ public void LogException(Exception exception, UnityEngine.Object context) // Unity routes both Debug.LogException and many uncaught main-thread // exceptions here. Default non-fatal; opt into fatal via launch option. var fatal = _treatUnityLogExceptionAsFatal; - Report(exception, handled: !fatal, source: "LogException", syncFlush: fatal); - _inner.LogException(exception, context); + var outcome = Report(exception, handled: !fatal, source: "LogException", syncFlush: fatal); + // Unity echoes this exception into logMessageReceivedThreaded as text, on this thread, from inside the + // next call. Expect it there so the callback can drop the second copy when this path delivered it, or + // skipped it as a duplicate (the first call delivered it). If Report skipped a nested call or the send + // failed or timed out, the echo is the only report left. + if (!global::Bugsee.WrapperPolicy.LogExceptionEcho.ShouldExpect(outcome)) + { + _inner.LogException(exception, context); + return; + } + + using (global::Bugsee.WrapperPolicy.LogExceptionEcho.Expect(exception)) + { + _inner.LogException(exception, context); + } } void OnLogMessageReceivedThreaded(string condition, string stackTrace, LogType type) @@ -142,8 +155,13 @@ void OnLogMessageReceivedThreaded(string condition, string stackTrace, LogType t return; } - // ILogHandler.LogException already reported Debug.LogException path — - // dedupe Unity-routed uncaught exceptions that also hit this callback. + // ILogHandler.LogException already reported the Debug.LogException path; this is its echo. + if (global::Bugsee.WrapperPolicy.LogExceptionEcho.IsEcho(condition)) + { + return; + } + + // Unity-routed uncaught exceptions that hit this callback more than once. var key = (condition ?? "") + "\n" + (stackTrace ?? ""); if (IsDuplicate(key)) { @@ -177,44 +195,59 @@ void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e } #endif - void Report(Exception exception, bool handled, string source, bool syncFlush = false) + // Per thread: a counter shared by all threads made a Debug.LogException on thread B skip Report while + // thread A was inside it, and (with the echo below) the exception was then never reported at all. + [ThreadStatic] + static int _reportDepth; + + /// + /// Delivered when delivered it; Duplicate when the same exception (type, + /// message and stack of the whole chain) was just reported; NotDelivered when it was skipped as a nested call, or the send failed or timed out. + /// + EchoOutcome Report(Exception exception, bool handled, string source, bool syncFlush = false) { if (exception == null) { - return; + return EchoOutcome.NotDelivered; } - if (Interlocked.Increment(ref _reentrancy) > 1) + if (_reportDepth > 0) { - Interlocked.Decrement(ref _reentrancy); - return; + return EchoOutcome.NotDelivered; } + _reportDepth++; try { - var key = exception.GetType().FullName + "\n" + (exception.StackTrace ?? "") + "\n" + source; - if (IsDuplicate(key)) + if (IsDuplicate(global::Bugsee.WrapperPolicy.LogExceptionEcho.DuplicateKey(exception, source))) { - return; + return EchoOutcome.Duplicate; } var payload = ManagedExceptionPayload.FromException(exception); - DispatchPayload(payload, handled, syncFlush || !handled); + return DispatchPayload(payload, handled, syncFlush || !handled) + ? EchoOutcome.Delivered + : EchoOutcome.NotDelivered; } finally { - Interlocked.Decrement(ref _reentrancy); + _reportDepth--; } } - void DispatchPayload(ManagedExceptionPayload.Payload payload, bool handled, bool syncFlush) + /// + /// True when the payload was sent, or queued for the main thread on the non-blocking path (where the echo + /// would only be queued behind it). False when there was nothing to send, the send threw, or a sync flush + /// timed out. + /// + bool DispatchPayload(ManagedExceptionPayload.Payload payload, bool handled, bool syncFlush) { if (payload == null || _bridge == null) { - return; + return false; } - void Send() + bool Send() { try { @@ -226,34 +259,40 @@ void Send() { _bridge.LogUnhandledExceptionPayload(payload, null); } + return true; } catch (Exception ex) { // Never re-enter pipeline via Debug.LogException. try { _inner.LogFormat(LogType.Warning, null, "Bugsee ExceptionPipeline: {0}", ex.Message); } catch { /* ignore */ } + return false; } } if (MainThreadDispatcher.IsMainThread) { - Send(); - return; + return Send(); } if (syncFlush) { - MainThreadDispatcher.RunSync(Send, 4000); - } - else - { - MainThreadDispatcher.Run(Send); + // On a timeout the queued Send may still run later; reporting twice beats losing the crash. + var sent = false; + var completed = MainThreadDispatcher.RunSync(() => { sent = Send(); }, 4000); + return completed && sent; } + + MainThreadDispatcher.Run(() => Send()); + return true; } + // Time.realtimeSinceStartup throws off the main thread, and log callbacks arrive on any thread. + static readonly System.Diagnostics.Stopwatch DedupeClock = System.Diagnostics.Stopwatch.StartNew(); + bool IsDuplicate(string key) { - var now = (double)Time.realtimeSinceStartup; + var now = DedupeClock.Elapsed.TotalSeconds; lock (Gate) { if (_lastDedupeKey == key && (now - _lastDedupeTime) < 0.75) diff --git a/Runtime/WrapperPolicy/LogExceptionEcho.cs b/Runtime/WrapperPolicy/LogExceptionEcho.cs new file mode 100644 index 0000000..e153c4b --- /dev/null +++ b/Runtime/WrapperPolicy/LogExceptionEcho.cs @@ -0,0 +1,142 @@ +using System; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Debug.LogException reaches the SDK twice: through ILogHandler.LogException (a real + /// ) and again through Application.logMessageReceivedThreaded, which only has + /// Unity's rendered text. Unity raises that callback synchronously, on the logging thread, from inside the + /// default handler's LogException (checked in a Unity 2021.3.45 Editor, main and background thread). So + /// the echo is recognised by being raised on this thread while 's scope is open around that + /// call - not by a time window, which let another thread's exception with the same text be taken for it. + /// + /// The text is still compared, so a different exception logged from inside that call (a nested log callback) + /// is not dropped. Unity renders the INNERMOST exception of a chain as exactly "TypeName: message" (the outer + /// ones go into the trace as "Rethrow as ..."), and as the bare "TypeName" when the message is blank. The + /// expectation is consumed by the echo it explains. + /// + public static class LogExceptionEcho + { + /// What the ILogHandler.LogException path did with the exception. + public enum Outcome + { + /// Sent, or queued for the main thread. + Delivered, + /// Skipped as a repeat of the same exception (see ) just reported. + Duplicate, + /// Skipped (nested call), the send threw, or a sync flush timed out. + NotDelivered, + } + + /// + /// Whether to (and so drop) the echo. A duplicate was already reported by the first + /// call, so its echo would file the same exception again from the text; only when nothing was delivered is + /// the echo the report. + /// + public static bool ShouldExpect(Outcome outcome) => outcome != Outcome.NotDelivered; + + /// + /// Key for the short-window repeat check behind . Since a duplicate's echo + /// is dropped, it must name the same exception: type, message and stack of the whole chain (inner and + /// aggregate ones too), plus the source. Type + stack alone merged a per-item message thrown from one line, + /// and every unthrown new Exception(msg) (null stack), and their echoes were dropped with them. + /// + public static string DuplicateKey(Exception exception, string source) + { + var key = new System.Text.StringBuilder(); + AppendIdentity(key, exception, 0); + return key.Append('\n').Append(source).ToString(); + } + + static void AppendIdentity(System.Text.StringBuilder key, Exception exception, int depth) + { + if (exception == null) return; + if (depth > 16) { key.Append("\n..."); return; } + key.Append(exception.GetType().FullName).Append('\n') + .Append(exception.Message).Append('\n') + .Append(exception.StackTrace).Append('\n'); + if (exception is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + key.Append("--inner--\n"); + AppendIdentity(key, inner, depth + 1); + } + return; + } + if (exception.InnerException != null) + { + key.Append("--inner--\n"); + AppendIdentity(key, exception.InnerException, depth + 1); + } + } + + internal sealed class Entry + { + public string Name; + public string FullName; + public string Message; + public bool Consumed; + public Entry Outer; + } + + [ThreadStatic] + static Entry _expected; + + /// Open around the call that raises the echo; dispose when it returns. + public static Scope Expect(Exception exception) + { + if (exception == null) return default; + var inner = exception; + while (inner.InnerException != null) inner = inner.InnerException; + var type = inner.GetType(); + var entry = new Entry + { + Name = type.Name, + FullName = type.FullName, + Message = inner.Message ?? "", + Outer = _expected, + }; + _expected = entry; + return new Scope(entry); + } + + /// True for the echo of the exception expected on this thread, once. + public static bool IsEcho(string condition) + { + var entry = _expected; + if (string.IsNullOrEmpty(condition) || entry == null || entry.Consumed) return false; + if (!Matches(condition, entry)) return false; + entry.Consumed = true; + return true; + } + + public readonly struct Scope : IDisposable + { + readonly Entry _entry; + + internal Scope(Entry entry) { _entry = entry; } + + public void Dispose() + { + if (_entry != null && _expected == _entry) _expected = _entry.Outer; + } + } + + static bool Matches(string condition, Entry entry) + { + foreach (var name in new[] { entry.Name, entry.FullName }) + { + if (name == null) continue; + // Unity prints ": message" only when the message is not blank. + var expected = string.IsNullOrWhiteSpace(entry.Message) ? name : name + ": " + entry.Message; + if (condition == expected + || condition.StartsWith(expected + "\n", StringComparison.Ordinal) + || condition.StartsWith(expected + "\r", StringComparison.Ordinal)) + return true; + } + + return false; + } + } +} diff --git a/Runtime/WrapperPolicy/LogExceptionEcho.cs.meta b/Runtime/WrapperPolicy/LogExceptionEcho.cs.meta new file mode 100644 index 0000000..64c1b28 --- /dev/null +++ b/Runtime/WrapperPolicy/LogExceptionEcho.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e34e0e4bff14ef1bf16241de24b2922 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/LogExceptionEchoTests.cs b/Tests~/WrapperPolicy/LogExceptionEchoTests.cs new file mode 100644 index 0000000..6b11171 --- /dev/null +++ b/Tests~/WrapperPolicy/LogExceptionEchoTests.cs @@ -0,0 +1,247 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class LogExceptionEchoTests + { + static readonly Exception Thrown = new InvalidOperationException("E2E StableA marker-1"); + + static bool EchoWhileExpecting(Exception exception, string condition) + { + using (LogExceptionEcho.Expect(exception)) return LogExceptionEcho.IsEcho(condition); + } + + [Test] + public void Unity_rendered_text_raised_inside_the_call_is_an_echo() + { + Assert.That(EchoWhileExpecting(Thrown, "InvalidOperationException: E2E StableA marker-1"), Is.True); + } + + [Test] + public void The_full_type_name_spelling_matches_too() + { + Assert.That(EchoWhileExpecting(Thrown, "System.InvalidOperationException: E2E StableA marker-1"), Is.True); + } + + [Test] + public void A_different_exception_is_not_an_echo() + { + Assert.That(EchoWhileExpecting(Thrown, "InvalidOperationException: something else"), Is.False); + Assert.That(EchoWhileExpecting(Thrown, "ArgumentException: E2E StableA marker-1"), Is.False); + } + + // Unity raises the echo from inside the call; once it returned, the same text is a new event. + [Test] + public void The_same_text_after_the_call_returned_is_a_new_event() + { + using (LogExceptionEcho.Expect(Thrown)) { } + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: E2E StableA marker-1"), Is.False); + } + + [Test] + public void Nothing_expected_means_nothing_is_an_echo() + { + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: x"), Is.False); + Assert.That(LogExceptionEcho.IsEcho(null), Is.False); + Assert.That(EchoWhileExpecting(Thrown, ""), Is.False); + Assert.That(EchoWhileExpecting(null, "InvalidOperationException: x"), Is.False); + } + + // Thread A is inside its LogException when thread B logs an exception with the same text (default + // NullReferenceException messages are identical across throw sites). B's is a real event, and A's own + // echo must still be recognised afterwards. + [Test] + public void Another_threads_exception_with_the_same_text_is_not_an_echo() + { + const string text = "NullReferenceException: Object reference not set to an instance of an object"; + var exception = new NullReferenceException("Object reference not set to an instance of an object"); + bool otherThread = true, ownEcho = false; + using (LogExceptionEcho.Expect(exception)) + { + var t = new Thread(() => otherThread = LogExceptionEcho.IsEcho(text)); + t.Start(); + t.Join(); + ownEcho = LogExceptionEcho.IsEcho(text); + } + + Assert.That(otherThread, Is.False, "another thread's exception was dropped as an echo"); + Assert.That(ownEcho, Is.True); + } + + // Unity prints "NullReferenceException" with no colon when the message is blank. + [Test] + public void A_blank_message_is_rendered_without_a_colon() + { + Assert.That(EchoWhileExpecting(new NullReferenceException(""), "NullReferenceException"), Is.True); + } + + [Test] + public void A_blank_message_does_not_swallow_a_different_exception_of_that_type() + { + Assert.That(EchoWhileExpecting(new NullReferenceException(""), "NullReferenceException: Object reference not set"), Is.False); + } + + // Unity renders the innermost exception of the chain as the condition. + [Test] + public void An_exception_with_an_inner_exception_is_matched_by_the_inner_text() + { + var chained = new InvalidOperationException("outer", new ArgumentException("inner")); + Assert.That(EchoWhileExpecting(chained, "ArgumentException: inner"), Is.True); + Assert.That(EchoWhileExpecting(chained, "InvalidOperationException: outer"), Is.False); + } + + [Test] + public void An_aggregate_exception_is_matched_by_its_first_inner_exception() + { + Assert.That(EchoWhileExpecting(new AggregateException(new ArgumentException("x")), "ArgumentException: x"), Is.True); + } + + [Test] + public void An_echo_is_consumed_so_it_cannot_swallow_a_second_report() + { + using (LogExceptionEcho.Expect(Thrown)) + { + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: E2E StableA marker-1"), Is.True); + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: E2E StableA marker-1"), Is.False); + } + } + + // Unity's condition is exactly "Name: message"; "contains" let "m1" match "m12". + [Test] + public void The_message_must_match_exactly_not_as_a_prefix() + { + var m1 = new InvalidOperationException("m1"); + Assert.That(EchoWhileExpecting(m1, "InvalidOperationException: m12"), Is.False); + Assert.That(EchoWhileExpecting(m1, "InvalidOperationException: m1"), Is.True); + Assert.That(EchoWhileExpecting(m1, "InvalidOperationException: m1\nmore"), Is.True); + } + + // A log callback that itself calls Debug.LogException nests a second call; each finds its own echo and + // the outer expectation is back when the inner call returns. + [Test] + public void A_nested_call_expects_its_own_echo_and_restores_the_outer_one() + { + using (LogExceptionEcho.Expect(new InvalidOperationException("outer-call"))) + { + using (LogExceptionEcho.Expect(new ArgumentException("inner-call"))) + { + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: outer-call"), Is.False); + Assert.That(LogExceptionEcho.IsEcho("ArgumentException: inner-call"), Is.True); + } + + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: outer-call"), Is.True); + } + + Assert.That(LogExceptionEcho.IsEcho("InvalidOperationException: outer-call"), Is.False); + } + + // A repeat of the same exception (catch { Debug.LogException(ex); throw; }, or two logs of it) is skipped + // by the stack dedupe because the first call already reported it; its echo must be dropped too, or the + // text path files it as a second issue. Only when nothing was delivered is the echo the report. + [Test] + public void The_echo_is_expected_after_a_delivery_or_a_duplicate_but_not_when_nothing_was_delivered() + { + Assert.That(LogExceptionEcho.ShouldExpect(LogExceptionEcho.Outcome.Delivered), Is.True); + Assert.That(LogExceptionEcho.ShouldExpect(LogExceptionEcho.Outcome.Duplicate), Is.True); + Assert.That(LogExceptionEcho.ShouldExpect(LogExceptionEcho.Outcome.NotDelivered), Is.False); + } + + static Exception ThrowFromOneSite(Exception exception) + { + try { throw exception; } + catch (Exception caught) { return caught; } + } + + // Duplicate drops the echo, so it must mean the same exception, not just the same type and stack: a + // per-item message thrown from one line, or new Exception(msg) logged unthrown (null stack), is a new event. + [Test] + public void The_duplicate_key_tells_apart_exceptions_that_differ_only_in_message() + { + Assert.That(LogExceptionEcho.DuplicateKey(new Exception("login failed"), "LogException"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey(new Exception("inventory sync failed"), "LogException"))); + + var a = ThrowFromOneSite(new InvalidOperationException("item 1")); + var b = ThrowFromOneSite(new InvalidOperationException("item 2")); + Assert.That(a.StackTrace, Is.EqualTo(b.StackTrace)); + Assert.That(LogExceptionEcho.DuplicateKey(a, "LogException"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey(b, "LogException"))); + } + + // Unity's echo names the innermost exception, so a chain that differs only inside is a different event. + [Test] + public void The_duplicate_key_covers_inner_and_aggregate_exceptions() + { + Assert.That(LogExceptionEcho.DuplicateKey(new Exception("load", new IOException("disk")), "LogException"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey(new Exception("load", new IOException("net")), "LogException"))); + Assert.That(LogExceptionEcho.DuplicateKey( + new AggregateException(new Exception("a"), new Exception("b")), "UnobservedTask"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey( + new AggregateException(new Exception("a"), new Exception("c")), "UnobservedTask"))); + } + + [Test] + public void The_duplicate_key_matches_the_same_exception_and_keeps_stack_and_source() + { + var ex = ThrowFromOneSite(new InvalidOperationException("same")); + Assert.That(LogExceptionEcho.DuplicateKey(ex, "LogException"), + Is.EqualTo(LogExceptionEcho.DuplicateKey(ex, "LogException"))); + Assert.That(LogExceptionEcho.DuplicateKey(ex, "LogException"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey(ex, "AppDomain"))); + Assert.That(LogExceptionEcho.DuplicateKey(ex, "LogException"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey(new InvalidOperationException("same"), "LogException")), + "the stack is part of the key"); + Assert.That(LogExceptionEcho.DuplicateKey(new Exception(), "LogException"), + Is.Not.EqualTo(LogExceptionEcho.DuplicateKey(new ArgumentException(), "LogException"))); + } + + // ExceptionPipeline (Unity-only, not compiled here): the echo may be dropped only when the first path + // delivered the exception, now or as the duplicate's first call. A nested call, a send that threw, or a + // sync flush that timed out must leave the echo to report. + [Test] + public void The_pipeline_suppresses_the_echo_only_after_a_confirmed_delivery() + { + var source = Code(File.ReadAllText(RepoFile("Runtime/Internal/ExceptionPipeline.cs"))); + + Assert.That(source, Does.Not.Contain("return payload != null")); + Assert.That(source, Does.Contain("bool DispatchPayload(")); + Assert.That(source, Does.Contain("bool Send()")); + Assert.That(Regex.IsMatch(source, @"var\s+completed\s*=\s*MainThreadDispatcher\.RunSync\("), Is.True, + "the RunSync result (false on timeout) must be used"); + Assert.That(source, Does.Contain("return completed && sent;")); + Assert.That(Regex.IsMatch(source, + @"if\s*\(\s*IsDuplicate\(\s*[\w.:]*LogExceptionEcho\.DuplicateKey\(\s*exception\s*,\s*source\s*\)\s*\)\s*\)\s*\{\s*return\s+EchoOutcome\.Duplicate;"), + Is.True, "Duplicate (which drops the echo) must use the full-identity key, told apart from a failed delivery"); + Assert.That(Regex.IsMatch(source, @"if\s*\(\s*_reportDepth\s*>\s*0\s*\)\s*\{\s*return\s+EchoOutcome\.NotDelivered;"), Is.True); + Assert.That(Regex.IsMatch(source, + @"return\s+DispatchPayload\([^;]*\?\s*EchoOutcome\.Delivered\s*:\s*EchoOutcome\.NotDelivered;"), Is.True); + Assert.That(Regex.IsMatch(source, @"if\s*\(\s*![\w.:]*LogExceptionEcho\.ShouldExpect\(\s*outcome\s*\)\s*\)"), Is.True); + Assert.That(source, Does.Not.Contain("!reported")); + Assert.That(source, Does.Contain("LogExceptionEcho.Expect(exception)")); + } + + // Source without // comments, so prose cannot satisfy or break an assertion. + static string Code(string source) => + string.Join("\n", source.Split('\n').Select(l => + { + var i = l.IndexOf("//", StringComparison.Ordinal); + return i >= 0 ? l.Substring(0, i) : l; + })); + + static string RepoFile(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) return Path.Combine(dir, relative); + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + } +} From a5d23aa08a3a3bc05083c2802bb7bebff6bb68a2 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:26:22 +0500 Subject: [PATCH 087/157] Upload this build's symbols zip, R8 mapping and line map, and derive the IL2CPP module id from that zip. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defects in the Android symbol upload, found by building the sample in four configurations and checking what reached staging (and, for the last one, by reading an iOS export). - The R8 mapping was never uploaded. Unity writes it next to the player as '_mapping.txt', but the uploader looked for exactly 'mapping.txt'. - The wrong build's files were chosen. Selection was 'newest modification time'. In the evidence below the build's own zip and mapping were OLDER (14:11 and 14:13) than another configuration's (14:14 and 14:15), so a Mono mapping and zip were uploaded for an IL2CPP build. (That Unity keeps cached outputs' old timestamps is the explanation that fits; the mechanism itself was not established.) - The il2cpp-linemap upload was skipped with 'no IL2CPP module UUID(s)' in every Android build. The module id was only looked for in loose files, but the unstripped libil2cpp.so is inside the symbols zip (the APK has stripped copies); and it was then reading the wrong zip (above). - The linemap finder searched the build's parent folder and took the newest LineNumberMappings.json, so an iOS export was pointed at the ANDROID build's file (seen in BugseePendingIl2CppLinemap.txt), which an iOS archive would have uploaded under the iOS module id. How a file is chosen now (Runtime/WrapperPolicy/BuildArtifactChoice, unit-tested): a file is this build's only if it has exactly the name Unity gives it - '_mapping.txt' and '--v-.symbols.zip' - built from the project's own settings, so a version that does not start with a digit ('v1.0', 'beta') still matches and 'Game' is not confused with 'Game2', 'Game-min' or 'Game-2024'. The backend is this build's Android scripting backend ('IL2CPP' / 'Mono2x', the ScriptingImplementation name Unity writes), matched exactly, so a Mono and an IL2CPP zip of the same player, version and code left in a reused output folder can never be confused. Unity 2021.3.45 has a single format for this name ('{0}-{1}-v{2}-{3}.symbols.zip' in UnityEditor.Android.Extensions) and wrote '-IL2CPP' / '-Mono2x' for all four sample configurations; a name without the backend is not accepted. Nothing falls back to 'newest'. Gradle's own names (mapping.txt, native-debug-symbols.zip) are accepted only for an exported project, and an exported project is searched only inside its own folder, never its parent. The line map is searched only in this build's folder: '_BackUpThisFolder_ButDontShipItWithYourGame' beside an APK, or the export folder; no match means a warning and no upload. The module-id scan for loose libil2cpp.so / UnityFramework (iOS always; Android when no symbols zip yields an id) uses that same single folder (BuildArtifactChoice.Il2CppOutputRoot), so with Builds/Android (a Gradle export) beside Builds/iOS the iOS export no longer takes the Android libil2cpp.so's build-id and uploads its own line map under it; with no id it defers to the archive script as before. The upload and the module-id lookup share one zip selector (FindSymbolsZip), the zip is read before any loose library, the entry must be libil2cpp.so or its .sym/.dbg spellings, and its bytes must start with the ELF magic. The player name of an exported folder is the folder name. Version, code and backend in that name are the values at the end of preprocess (BugseeBuildSettingsSnapshot, last IPreprocessBuildWithReport), not the live Player Settings at postprocess 900/910: Unity 2021.3.45 reads them live in MoveFinalPackage.GetSymbols, which runs before user postprocess callbacks, so a bundleVersionCode++ in an earlier postprocess callback would otherwise make this build's zip a non-match. The recorded values are used only for the same output path (BuildArtifactChoice.BuildTimeSettings); otherwise the live ones. The Android --version / --build upload metadata uses the same values unless BUGSEE_VERSION / BUGSEE_BUILD are set. Evidence (minified IL2CPP release build against staging): 'derived IL2CPP module UUID(s) from native binary: d33052bd...', equal to the build-id the CLI printed for that build's libil2cpp.so; then the linemap, the mapping and the zip uploaded, each under that build's own name. Not run: a Gradle-export build, an AAB, a multi-ABI zip (the code collects every libil2cpp.so in the zip and passes them comma-joined, per the CLI README), or a build with a non-numeric version, or a build with a postprocess version incrementor (the snapshot wiring is Editor-only and not unit-tested). The Editor code type-checks against Unity 2021.3.45's assemblies; the selection rules are covered by BuildArtifactChoiceTests (including non-digit versions, the 'Game-2024' collision and the ELF magic). Unchanged and worth knowing: an exported project may still contain a mapping.txt or native-debug zip left by an earlier Gradle run (Unity's export does not run Gradle), which is then uploaded under this build's version - run the upload after Gradle for exports. A stale loose libil2cpp.so under the output folder is still used for the module id when no symbols zip exists. iOS/tvOS symbol upload is not touched except that the line-map finder and the module-id scan no longer reach into the parent folder. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeBuildIdentity.cs | 47 ++- Editor/BugseeIl2CppLinemapUpload.cs | 19 +- Editor/BugseeIl2CppModuleIdentity.cs | 75 ++++- Editor/BugseeSymbolUpload.cs | 165 ++++++++--- Runtime/WrapperPolicy/BuildArtifactChoice.cs | 122 ++++++++ .../WrapperPolicy/BuildArtifactChoice.cs.meta | 11 + .../WrapperPolicy/BuildArtifactChoiceTests.cs | 272 ++++++++++++++++++ 7 files changed, 646 insertions(+), 65 deletions(-) create mode 100644 Runtime/WrapperPolicy/BuildArtifactChoice.cs create mode 100644 Runtime/WrapperPolicy/BuildArtifactChoice.cs.meta create mode 100644 Tests~/WrapperPolicy/BuildArtifactChoiceTests.cs diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 3091ff1..c77aa45 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -1,7 +1,9 @@ #if UNITY_EDITOR using System; using System.IO; +using Bugsee.WrapperPolicy; using UnityEditor; +using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; @@ -13,9 +15,31 @@ internal static class BugseeBuildIdentity const string ResourcesFolder = "Assets/Resources"; const string ModuleUuidAsset = "BugseeIl2CppModuleUuids.txt"; - public static string ResolveVersion() => + /// Android name settings as they were at the end of preprocess (see BugseeBuildSettingsSnapshot). + internal static PlayerNameSettings Recorded; + + /// + /// The version, code and backend Unity named this Android build's files with: the values recorded for this + /// output at preprocess, or the live Player Settings when none were (BuildArtifactChoice.BuildTimeSettings). + /// + internal static PlayerNameSettings AndroidNameSettings(BuildReport report) + { + var output = report?.summary.outputPath; + return BuildArtifactChoice.BuildTimeSettings(Recorded, output, LiveAndroidNameSettings(output)); + } + + internal static PlayerNameSettings LiveAndroidNameSettings(string outputPath) => + new PlayerNameSettings( + outputPath, + PlayerSettings.bundleVersion, + PlayerSettings.Android.bundleVersionCode, + PlayerSettings.GetScriptingBackend(NamedBuildTarget.Android).ToString()); + + public static string ResolveVersion(BuildReport report) => Environment.GetEnvironmentVariable("BUGSEE_VERSION") - ?? PlayerSettings.bundleVersion + ?? (report?.summary.platform == BuildTarget.Android + ? AndroidNameSettings(report).BundleVersion + : PlayerSettings.bundleVersion) ?? "0.0.0"; public static string ResolveBuildNumber(BuildReport report) @@ -36,7 +60,7 @@ public static string ResolveBuildNumber(BuildReport report) } break; case BuildTarget.Android: - return PlayerSettings.Android.bundleVersionCode.ToString(); + return AndroidNameSettings(report).VersionCode.ToString(); } return DateTime.UtcNow.ToString("yyyyMMddHHmmss"); @@ -85,5 +109,22 @@ public static void WriteRuntimeModuleUuidFile(System.Collections.Generic.IList + /// Records the Android version, code and backend at the end of preprocess. Unity names the symbols zip from + /// these during the build; a postprocess callback ordered before Bugsee's (a version-code incrementor) can + /// change them before the upload looks for the zip. Last in preprocess so preprocess-time bumps are included. + /// + sealed class BugseeBuildSettingsSnapshot : IPreprocessBuildWithReport + { + public int callbackOrder => int.MaxValue; + + public void OnPreprocessBuild(BuildReport report) + { + BugseeBuildIdentity.Recorded = report.summary.platform == BuildTarget.Android + ? BugseeBuildIdentity.LiveAndroidNameSettings(report.summary.outputPath) + : null; + } + } } #endif diff --git a/Editor/BugseeIl2CppLinemapUpload.cs b/Editor/BugseeIl2CppLinemapUpload.cs index 058f418..e1947b3 100644 --- a/Editor/BugseeIl2CppLinemapUpload.cs +++ b/Editor/BugseeIl2CppLinemapUpload.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; +using Bugsee.WrapperPolicy; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; @@ -87,7 +88,7 @@ public void OnPostprocessBuild(BuildReport report) return; } - Upload(jsonPath, uuids, BugseeBuildIdentity.ResolveVersion(), + Upload(jsonPath, uuids, BugseeBuildIdentity.ResolveVersion(report), BugseeBuildIdentity.ResolveBuildNumber(report)); } @@ -136,17 +137,19 @@ static void TryWriteIosPendingLinemap(BuildReport report, string jsonPath) } } + // Only this build's own output is searched. A single APK's IL2CPP output is in the folder Unity creates + // beside it ("_BackUpThisFolder_ButDontShipItWithYourGame"); an exported project (Xcode, Gradle) + // is a folder of its own. Searching the parent as well found another build's file: an iOS export + // was pointed at the Android build's LineNumberMappings.json, which would be uploaded under the iOS module + // id. No match means no upload (with a warning), never someone else's file. static string FindLineNumberMappings(BuildReport report) { var roots = new List(); - if (!string.IsNullOrEmpty(report.summary.outputPath)) + var root = BuildArtifactChoice.Il2CppOutputRoot( + report.summary.outputPath, BugseeSymbolUpload.OutputIsDirectory(report)); + if (root != null) { - roots.Add(report.summary.outputPath); - var parent = Path.GetDirectoryName(report.summary.outputPath); - if (!string.IsNullOrEmpty(parent)) - { - roots.Add(parent); - } + roots.Add(root); } string bestPreferred = null; diff --git a/Editor/BugseeIl2CppModuleIdentity.cs b/Editor/BugseeIl2CppModuleIdentity.cs index e3a08b5..45a497b 100644 --- a/Editor/BugseeIl2CppModuleIdentity.cs +++ b/Editor/BugseeIl2CppModuleIdentity.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Text; +using Bugsee.WrapperPolicy; +using UnityEditor; using UnityEditor.Build.Reporting; using UnityEngine; using Debug = UnityEngine.Debug; @@ -28,6 +30,24 @@ public static List DiscoverFromBuild(BuildReport report) { var found = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Android: the player output is an APK holding only stripped copies, and the unstripped + // libil2cpp.so Unity writes beside it lives inside this build's symbols zip. Read it first, so a + // stale loose library from an earlier build or an old Gradle export cannot supply the id. The id + // is the same GNU build-id the device reports in a crash. + if (report.summary.platform == BuildTarget.Android) + { + var zip = BugseeSymbolUpload.FindSymbolsZip(report); + if (zip != null) + { + TryCollectFromSymbolsZip(zip, found, seen); + if (found.Count > 0) + { + return found; + } + } + } + var candidates = new List<(string path, DateTime mtime, int score)>(); foreach (var root in BuildRoots(report)) @@ -79,9 +99,47 @@ public static List DiscoverFromBuild(BuildReport report) break; } } + return found; } + // The GNU build-id note is in the first read-only segment (found through the program headers), a few + // hundred bytes into the file; the first MiB covers it. A note past the buffer is skipped safely. + const int ElfPrefixBytes = 1024 * 1024; + + static void TryCollectFromSymbolsZip(string zipPath, List found, HashSet seen) + { + try + { + using (var archive = new System.IO.Compression.ZipArchive(File.OpenRead(zipPath), System.IO.Compression.ZipArchiveMode.Read)) + { + foreach (var entry in archive.Entries) + { + if (!BuildArtifactChoice.IsIl2CppLibraryName(entry.Name)) continue; + using (var stream = entry.Open()) + { + var prefix = new byte[Math.Min(ElfPrefixBytes, (int)Math.Min(entry.Length, int.MaxValue))]; + var read = 0; + while (read < prefix.Length) + { + var n = stream.Read(prefix, read, prefix.Length - read); + if (n <= 0) break; + read += n; + } + + if (!BuildArtifactChoice.LooksLikeElf(prefix)) continue; + var id = TryReadElfBuildId(prefix); + if (!string.IsNullOrEmpty(id) && seen.Add(id)) found.Add(id); + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: reading IL2CPP identity from the symbols zip failed: {ex.Message}"); + } + } + static void CollectCandidateBinaries(string root, List<(string path, DateTime mtime, int score)> into) { foreach (var name in CandidateNames) @@ -156,19 +214,16 @@ static int ScorePath(string path, string name) return score; } + // Only this build's own IL2CPP output folder, the same one the line map is taken from (see + // BuildArtifactChoice.Il2CppOutputRoot): never the project cwd (stale Library/Bee binaries) and never the + // output's parent, where a sibling build's libil2cpp.so would key this build's line map to its module id. static IEnumerable BuildRoots(BuildReport report) { - // Only scan the player output tree — never the whole project cwd - // (that pulls stale Library/Bee binaries and poisons UUID identity). - if (!string.IsNullOrEmpty(report.summary.outputPath)) + var root = BuildArtifactChoice.Il2CppOutputRoot( + report.summary.outputPath, BugseeSymbolUpload.OutputIsDirectory(report)); + if (root != null) { - var outPath = report.summary.outputPath; - yield return outPath; - var parent = Path.GetDirectoryName(outPath); - if (!string.IsNullOrEmpty(parent)) - { - yield return parent; - } + yield return root; } } diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 4ef340c..4835b3f 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using System; +using Bugsee.WrapperPolicy; using System.Collections.Generic; using System.IO; using System.Text; @@ -33,7 +34,7 @@ public void OnPostprocessBuild(BuildReport report) return; } - var version = BugseeBuildIdentity.ResolveVersion(); + var version = BugseeBuildIdentity.ResolveVersion(report); var build = BugseeBuildIdentity.ResolveBuildNumber(report); switch (report.summary.platform) @@ -69,15 +70,19 @@ public static void OnIosPostProcess(BuildTarget target, string pathToBuiltProjec static void UploadAndroidSymbols(BuildReport report, string version, string build) { var roots = CollectRoots(report); - UploadIfFound(roots, "**/mapping.txt", "proguard", version, build, preferNewest: true); + // Unity writes the R8 mapping beside the player as "_mapping.txt" (a bare "mapping.txt" never + // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. + var stem = OutputStem(report); + var allowGeneric = OutputIsDirectory(report); + UploadIfFound(roots, "*mapping.txt", "proguard", version, build, + path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric)); // Unity "Create symbols.zip" / Gradle native debug symbols. UploadSymbolsZipOrElf(report, roots, version, build); } static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build) { - string bestZip = null; - DateTime bestZipTime = DateTime.MinValue; + var bestZip = FindSymbolsZip(report); var elfDirs = new List(); foreach (var root in roots) @@ -89,28 +94,6 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string try { - foreach (var zip in Directory.EnumerateFiles(root, "*.zip", SearchOption.AllDirectories)) - { - var name = Path.GetFileName(zip); - if (name.IndexOf("symbols", StringComparison.OrdinalIgnoreCase) < 0 - && name.IndexOf("native-debug", StringComparison.OrdinalIgnoreCase) < 0) - { - continue; - } - if (IsExcludedPath(zip)) - { - continue; - } - DateTime mtime; - try { mtime = File.GetLastWriteTimeUtc(zip); } - catch { mtime = DateTime.MinValue; } - if (bestZip == null || mtime >= bestZipTime) - { - bestZip = zip; - bestZipTime = mtime; - } - } - foreach (var so in Directory.EnumerateFiles(root, "libil2cpp.so", SearchOption.AllDirectories)) { if (IsExcludedPath(so)) @@ -186,16 +169,15 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string static void UploadIfFound( List roots, - string fileName, + string pattern, string type, string version, string build, - bool preferNewest) + Func rank) { - // fileName may be a plain name (mapping.txt) - var plain = fileName.Replace("**/", ""); string best = null; - DateTime bestTime = DateTime.MinValue; + var bestRank = BuildArtifactChoice.Reject; + var bestTime = DateTime.MinValue; foreach (var root in roots) { if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) @@ -204,24 +186,26 @@ static void UploadIfFound( } try { - foreach (var file in Directory.EnumerateFiles(root, plain, SearchOption.AllDirectories)) + foreach (var file in Directory.EnumerateFiles(root, pattern, SearchOption.AllDirectories)) { if (IsExcludedPath(file)) { continue; } + var fileRank = rank(file); + if (fileRank == BuildArtifactChoice.Reject) + { + continue; + } DateTime mtime; try { mtime = File.GetLastWriteTimeUtc(file); } catch { mtime = DateTime.MinValue; } - if (best == null || mtime >= bestTime) + if (best == null || BuildArtifactChoice.Better(fileRank, mtime, bestRank, bestTime)) { best = file; + bestRank = fileRank; bestTime = mtime; } - if (!preferNewest) - { - break; - } } } catch { /* ignore */ } @@ -246,18 +230,111 @@ static void UploadIfFound( } } - static List CollectRoots(BuildReport report) + /// + /// This build's symbols zip, or null. Shared by the upload and by the IL2CPP module-id lookup so both + /// use the same file. + /// + internal static string FindSymbolsZip(BuildReport report) { - var roots = new List(); - if (!string.IsNullOrEmpty(report.summary.outputPath)) + var stem = OutputStem(report); + var allowGeneric = OutputIsDirectory(report); + // Unity puts the scripting backend in the zip name; matching it keeps the other backend's zip of the + // same player, version and code (a reused output folder) from being chosen by modification time. + // Version, code and backend are the build-time values, not whatever a later postprocessor set. + var names = BugseeBuildIdentity.AndroidNameSettings(report); + string best = null; + var bestRank = BuildArtifactChoice.Reject; + var bestTime = DateTime.MinValue; + foreach (var root in CollectRoots(report)) { - roots.Add(report.summary.outputPath); - var parent = Path.GetDirectoryName(report.summary.outputPath); - if (!string.IsNullOrEmpty(parent)) + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) { - roots.Add(parent); + continue; } + + try + { + foreach (var zip in Directory.EnumerateFiles(root, "*.zip", SearchOption.AllDirectories)) + { + if (IsExcludedPath(zip)) + { + continue; + } + var rank = BuildArtifactChoice.SymbolsZipRank( + zip, stem, allowGeneric, names.BundleVersion, names.VersionCode, names.Backend); + if (rank == BuildArtifactChoice.Reject) + { + continue; + } + DateTime mtime; + try { mtime = File.GetLastWriteTimeUtc(zip); } + catch { mtime = DateTime.MinValue; } + if (best == null || BuildArtifactChoice.Better(rank, mtime, bestRank, bestTime)) + { + best = zip; + bestRank = rank; + bestTime = mtime; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: Android symbols zip scan failed under {root}: {ex.Message}"); + } + } + + return best; + } + + /// + /// Name of the player being built ("MyGame" for MyGame.apk, "proj" for an exported project folder). + /// Unity names the symbols zip and the R8 mapping after it, which tells this build's files apart from + /// other builds' in the same folder. + /// + internal static string OutputStem(BuildReport report) + { + var path = report?.summary.outputPath; + if (string.IsNullOrEmpty(path)) return null; + if (OutputIsDirectory(report)) + { + return Path.GetFileName(path.TrimEnd('/', '\\')); } + + return Path.GetFileNameWithoutExtension(path); + } + + /// An exported project (Android Gradle project, Xcode folder), not a single APK/AAB file. + internal static bool OutputIsDirectory(BuildReport report) + { + var path = report?.summary.outputPath; + return !string.IsNullOrEmpty(path) + && (Directory.Exists(path) || path.EndsWith("/", StringComparison.Ordinal) || path.EndsWith("\\", StringComparison.Ordinal)); + } + + // A single APK/AAB sits among its siblings in one folder, so that folder is searched (by exact name, see + // BuildArtifactChoice). An exported project is a folder of its own: search only inside it, never its + // parent, where sibling exports and unrelated builds live. + static List CollectRoots(BuildReport report) + { + var roots = new List(); + var output = report.summary.outputPath; + if (string.IsNullOrEmpty(output)) + { + return roots; + } + + if (OutputIsDirectory(report)) + { + roots.Add(output); + return roots; + } + + var parent = Path.GetDirectoryName(output); + if (!string.IsNullOrEmpty(parent)) + { + roots.Add(parent); + } + return roots; } diff --git a/Runtime/WrapperPolicy/BuildArtifactChoice.cs b/Runtime/WrapperPolicy/BuildArtifactChoice.cs new file mode 100644 index 0000000..51e8e4f --- /dev/null +++ b/Runtime/WrapperPolicy/BuildArtifactChoice.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Decides which of several build artifacts in one folder (the symbols zip, the R8 mapping, the IL2CPP line + /// map folder) belong to the build that just finished. + /// + /// Modification time cannot decide it: an incremental build copies cached outputs and keeps their old + /// timestamps, so another configuration's file can look newer than this build's own. A name prefix cannot + /// either: "Game" is a prefix of "Game2", "Game-min" and "Game-2024". So a file counts as this build's only if + /// it has exactly the name Unity gives it - "<player>_mapping.txt" and + /// "<player>-<bundleVersion>-v<versionCode>-<backend>.symbols.zip" - and nothing falls + /// back to "newest". + /// + /// Generic names (Gradle's own mapping.txt / native-debug-symbols.zip) are accepted only when the output is an + /// exported Gradle project, where Gradle names things itself. + /// + public static class BuildArtifactChoice + { + public const int Reject = 0; + public const int Generic = 1; + public const int Named = 2; + + public static int MappingRank(string path, string stem, bool allowGeneric) + { + var name = Path.GetFileName(path ?? ""); + if (name.Length == 0) return Reject; + if (!string.IsNullOrEmpty(stem) && Eq(name, stem + "_mapping.txt")) return Named; + return allowGeneric && Eq(name, "mapping.txt") ? Generic : Reject; + } + + /// + /// is this build's scripting backend as Unity spells it in the name + /// (ScriptingImplementation: "IL2CPP" / "Mono2x"). It is part of the exact name, so the other + /// backend's zip left in a reused output folder - same player, version and code - is never this build's. + /// + public static int SymbolsZipRank(string path, string stem, bool allowGeneric, string bundleVersion, int versionCode, string backend) + { + var name = Path.GetFileName(path ?? ""); + if (!name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return Reject; + if (!string.IsNullOrEmpty(stem) && !string.IsNullOrEmpty(backend) + && Eq(name, SymbolsZipName(stem, bundleVersion, versionCode, backend))) + { + return Named; + } + + return allowGeneric && name.StartsWith("native-debug", StringComparison.OrdinalIgnoreCase) ? Generic : Reject; + } + + /// Unity's Android symbols zip name, "{0}-{1}-v{2}-{3}.symbols.zip" (UnityEditor.Android.Extensions). + public static string SymbolsZipName(string stem, string bundleVersion, int versionCode, string backend) => + stem + "-" + bundleVersion + "-v" + versionCode + "-" + backend + ".symbols.zip"; + + /// + /// The settings this build's file names are made from. Unity names the symbols zip from Player Settings as + /// they are during the build, but a postprocess callback that runs before ours (a version-code incrementor) + /// can change them before we look. So the values recorded at the end of preprocess for this same output are + /// used, and the live ones only when none were recorded for it (a call outside a build). + /// + public static PlayerNameSettings BuildTimeSettings(PlayerNameSettings recorded, string outputPath, PlayerNameSettings live) => + recorded != null && SameOutput(recorded.OutputPath, outputPath) ? recorded : live; + + static bool SameOutput(string a, string b) => + !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) + && string.Equals(a.TrimEnd('/', '\\'), b.TrimEnd('/', '\\'), StringComparison.Ordinal); + + /// Unity's folder for the IL2CPP output of a player: "<player>_BackUpThisFolder_ButDontShipItWithYourGame". + public static string Il2CppBackupFolderName(string stem) => stem + "_BackUpThisFolder_ButDontShipItWithYourGame"; + + /// + /// The one folder that holds this build's IL2CPP output (line map, unstripped native library): an exported + /// project (Xcode, Gradle) is that folder; a single APK/AAB's is the "<player>_BackUpThisFolder..." + /// folder Unity creates beside it. Never the parent, where sibling builds live: an iOS export beside an + /// Android Gradle export would otherwise take the Android libil2cpp.so or line map as its own. + /// Null when there is no output path. + /// + public static string Il2CppOutputRoot(string outputPath, bool outputIsDirectory) + { + if (string.IsNullOrEmpty(outputPath)) return null; + if (outputIsDirectory) return outputPath; + var parent = Path.GetDirectoryName(outputPath); + var stem = Path.GetFileNameWithoutExtension(outputPath); + if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(stem)) return null; + return Path.Combine(parent, Il2CppBackupFolderName(stem)); + } + + /// Higher rank wins; modification time only breaks a tie between equally ranked files. + public static bool Better(int rank, DateTime mtime, int bestRank, DateTime bestMtime) => + rank != bestRank ? rank > bestRank : mtime >= bestMtime; + + /// libil2cpp.so, and the debug-file spellings Unity and the CLI use for it. + public static bool IsIl2CppLibraryName(string name) => + !string.IsNullOrEmpty(name) + && (name == "libil2cpp.so" || name == "libil2cpp.sym.so" || name == "libil2cpp.dbg.so" + || name == "libil2cpp.so.sym" || name == "libil2cpp.so.dbg" || name == "libil2cpp.so.debug"); + + /// True for the ELF magic: 0x7f 'E' 'L' 'F'. + public static bool LooksLikeElf(byte[] data) => + data != null && data.Length >= 4 && data[0] == 0x7f && data[1] == (byte)'E' && data[2] == (byte)'L' && data[3] == (byte)'F'; + + static bool Eq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + } + + /// Player Settings that Unity puts in an Android player's symbols zip name, for one output path. + public sealed class PlayerNameSettings + { + public PlayerNameSettings(string outputPath, string bundleVersion, int versionCode, string backend) + { + OutputPath = outputPath; + BundleVersion = bundleVersion; + VersionCode = versionCode; + Backend = backend; + } + + public string OutputPath { get; } + public string BundleVersion { get; } + public int VersionCode { get; } + public string Backend { get; } + } +} diff --git a/Runtime/WrapperPolicy/BuildArtifactChoice.cs.meta b/Runtime/WrapperPolicy/BuildArtifactChoice.cs.meta new file mode 100644 index 0000000..f56db7b --- /dev/null +++ b/Runtime/WrapperPolicy/BuildArtifactChoice.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2dfbc1ed4db4005a5abb5af3a34bcf4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/BuildArtifactChoiceTests.cs b/Tests~/WrapperPolicy/BuildArtifactChoiceTests.cs new file mode 100644 index 0000000..a64f4ef --- /dev/null +++ b/Tests~/WrapperPolicy/BuildArtifactChoiceTests.cs @@ -0,0 +1,272 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class BuildArtifactChoiceTests + { + const string Ver = "0.1.0"; + const int Code = 2; + + static int Zip(string path, string stem, bool generic = false, string version = Ver, int code = Code, string backend = "IL2CPP") => + BuildArtifactChoice.SymbolsZipRank(path, stem, generic, version, code, backend); + + [Test] + public void The_mapping_unity_names_after_the_player_is_this_builds() + { + Assert.That(BuildArtifactChoice.MappingRank("/o/Game_mapping.txt", "Game", false), Is.EqualTo(BuildArtifactChoice.Named)); + Assert.That(BuildArtifactChoice.MappingRank("/o/game_mapping.txt", "Game", false), Is.EqualTo(BuildArtifactChoice.Named)); + } + + // android-il2cpp-release and android-il2cpp-release-min are both real output names in the sample matrix. + [Test] + public void A_sibling_build_whose_name_starts_with_this_one_is_not_ours() + { + const string stem = "android-il2cpp-release"; + Assert.That(BuildArtifactChoice.MappingRank("/o/android-il2cpp-release-min_mapping.txt", stem, false), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(BuildArtifactChoice.MappingRank("/o/Game2_mapping.txt", "Game", false), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/android-il2cpp-release-min-0.1.0-v2-IL2CPP.symbols.zip", stem), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game2-0.1.0-v2-IL2CPP.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + } + + [Test] + public void The_symbols_zip_unity_names_after_the_player_is_this_builds() + { + Assert.That(Zip("/o/Game-0.1.0-v2-IL2CPP.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Named)); + Assert.That(Zip("/o/android-il2cpp-release-min-0.1.0-v2-IL2CPP.symbols.zip", "android-il2cpp-release-min"), Is.EqualTo(BuildArtifactChoice.Named)); + Assert.That(Zip("/o/Game-0.1.0-v2-Mono2x.symbols.zip", "Game", backend: "Mono2x"), Is.EqualTo(BuildArtifactChoice.Named)); + } + + // A reused output folder: Mono and IL2CPP builds of the same player, version and code leave two zips that + // differ only in the backend. Modification time must never get to choose between them. + [Test] + public void The_other_backends_zip_of_the_same_player_version_and_code_is_not_this_builds() + { + Assert.That(Zip("/o/Game-0.1.0-v2-Mono2x.symbols.zip", "Game", backend: "IL2CPP"), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game-0.1.0-v2-IL2CPP.symbols.zip", "Game", backend: "Mono2x"), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game-0.1.0-v2-IL2CPP.symbols.zip", "Game", backend: null), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game-0.1.0-v2-IL2CPP-extra.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + } + + // The names Unity 2021.3.45 wrote for the sample's E2E matrix (Builds/E2E): the backend is always there. + [TestCase("android-il2cpp-dev-0.1.0-v2-IL2CPP.symbols.zip", "android-il2cpp-dev", "IL2CPP")] + [TestCase("android-il2cpp-release-min-0.1.0-v2-IL2CPP.symbols.zip", "android-il2cpp-release-min", "IL2CPP")] + [TestCase("android-mono-dev-0.1.0-v2-Mono2x.symbols.zip", "android-mono-dev", "Mono2x")] + [TestCase("android-mono-release-min-0.1.0-v2-Mono2x.symbols.zip", "android-mono-release-min", "Mono2x")] + public void The_names_unity_2021_3_wrote_are_matched(string file, string stem, string backend) + { + Assert.That(BuildArtifactChoice.SymbolsZipName(stem, Ver, Code, backend), Is.EqualTo(file)); + Assert.That(Zip("/o/" + file, stem, backend: backend), Is.EqualTo(BuildArtifactChoice.Named)); + } + + // The version is part of the name and need not start with a digit: Unity writes whatever bundleVersion is. + [TestCase("v1.0")] + [TestCase("beta")] + [TestCase("1.0.0-rc1")] + [TestCase("2024.10")] + public void A_version_that_does_not_start_with_a_digit_still_matches(string version) + { + Assert.That(Zip("/o/Game-" + version + "-v7-IL2CPP.symbols.zip", "Game", false, version, 7), Is.EqualTo(BuildArtifactChoice.Named)); + } + + // 'Game' and 'Game-2024' share an output folder; the exact version and code tell their zips apart. + [Test] + public void A_player_whose_name_continues_with_a_dash_and_digits_is_not_confused_with_this_one() + { + Assert.That(Zip("/o/Game-2024-1.0-v1-IL2CPP.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game-2-0.1.0-v2-IL2CPP.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game-2024-1.0-v1-IL2CPP.symbols.zip", "Game-2024", false, "1.0", 1), Is.EqualTo(BuildArtifactChoice.Named)); + } + + [Test] + public void A_zip_of_another_version_or_code_of_the_same_player_is_not_this_builds() + { + Assert.That(Zip("/o/Game-0.0.9-v2-IL2CPP.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/Game-0.1.0-v1-IL2CPP.symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + } + + [Test] + public void Another_builds_file_is_never_chosen_when_the_player_is_known() + { + Assert.That(BuildArtifactChoice.MappingRank("/o/Other_mapping.txt", "Game", false), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(BuildArtifactChoice.MappingRank("/o/mapping.txt", "Game", false), Is.EqualTo(BuildArtifactChoice.Reject)); + Assert.That(Zip("/o/native-debug-symbols.zip", "Game"), Is.EqualTo(BuildArtifactChoice.Reject)); + } + + [Test] + public void Gradle_names_are_accepted_only_for_an_exported_project_and_rank_below_a_named_file() + { + Assert.That(BuildArtifactChoice.MappingRank("/p/outputs/mapping/release/mapping.txt", "proj", true), Is.EqualTo(BuildArtifactChoice.Generic)); + Assert.That(Zip("/p/native-debug-symbols.zip", "proj", true), Is.EqualTo(BuildArtifactChoice.Generic)); + Assert.That(BuildArtifactChoice.MappingRank("/o/proj_mapping.txt", "proj", true), Is.GreaterThan(BuildArtifactChoice.MappingRank("/o/mapping.txt", "proj", true))); + } + + [Test] + public void Not_a_zip_is_rejected() + { + Assert.That(Zip("/o/Game-0.1.0-v2-IL2CPP.symbols.tar", "Game", true), Is.EqualTo(BuildArtifactChoice.Reject)); + } + + // An incremental build copies cached outputs with their old timestamps, so another build's file can + // be newer than this build's own. It must still lose. + [Test] + public void An_older_file_of_this_build_beats_a_newer_file_of_another_build() + { + var own = new DateTime(2026, 10, 14, 14, 11, 43); + var other = new DateTime(2026, 10, 14, 14, 14, 46); + Assert.That(BuildArtifactChoice.Better(BuildArtifactChoice.Named, own, BuildArtifactChoice.Generic, other), Is.True); + Assert.That(BuildArtifactChoice.Better(BuildArtifactChoice.Generic, other, BuildArtifactChoice.Named, own), Is.False); + } + + [Test] + public void Time_decides_between_equally_ranked_files() + { + var older = new DateTime(2026, 1, 1); + var newer = new DateTime(2026, 1, 2); + Assert.That(BuildArtifactChoice.Better(BuildArtifactChoice.Named, newer, BuildArtifactChoice.Named, older), Is.True); + Assert.That(BuildArtifactChoice.Better(BuildArtifactChoice.Named, older, BuildArtifactChoice.Named, newer), Is.False); + } + + [Test] + public void The_il2cpp_folder_unity_creates_is_named_after_the_player() + { + Assert.That(BuildArtifactChoice.Il2CppBackupFolderName("Game"), Is.EqualTo("Game_BackUpThisFolder_ButDontShipItWithYourGame")); + } + + [TestCase("libil2cpp.so", true)] + [TestCase("libil2cpp.sym.so", true)] + [TestCase("libil2cpp.dbg.so", true)] + [TestCase("libil2cpp.so.debug", true)] + [TestCase("libil2cpp.so.sym", true)] + [TestCase("libil2cpp.so.json", false)] + [TestCase("libil2cpp.sometext", false)] + [TestCase("libunity.so", false)] + [TestCase("libil2cppx.so", false)] + [TestCase("", false)] + public void Il2cpp_library_names(string name, bool expected) + { + Assert.That(BuildArtifactChoice.IsIl2CppLibraryName(name), Is.EqualTo(expected)); + } + + [Test] + public void Only_data_that_starts_with_the_elf_magic_is_parsed_as_elf() + { + Assert.That(BuildArtifactChoice.LooksLikeElf(new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F', 2 }), Is.True); + Assert.That(BuildArtifactChoice.LooksLikeElf(new byte[] { 0x7b, (byte)'}', 0, 0 }), Is.False); + Assert.That(BuildArtifactChoice.LooksLikeElf(new byte[] { 0x7f, (byte)'E' }), Is.False); + Assert.That(BuildArtifactChoice.LooksLikeElf(null), Is.False); + } + + // Unity wrote Game-1.0-v5-IL2CPP.symbols.zip; a postprocess incrementor then set the live code to 6. + [Test] + public void The_settings_recorded_for_this_output_name_the_zip_not_the_live_ones() + { + var recorded = new PlayerNameSettings("/out/Game.apk", "1.0", 5, "IL2CPP"); + var live = new PlayerNameSettings("/out/Game.apk", "1.0", 6, "IL2CPP"); + var used = BuildArtifactChoice.BuildTimeSettings(recorded, "/out/Game.apk", live); + Assert.That(used, Is.SameAs(recorded)); + Assert.That(Zip("/out/Game-1.0-v5-IL2CPP.symbols.zip", "Game", version: used.BundleVersion, code: used.VersionCode, backend: used.Backend), + Is.EqualTo(BuildArtifactChoice.Named)); + Assert.That(BuildArtifactChoice.BuildTimeSettings(recorded, "/out/Game.apk/", live), Is.SameAs(recorded)); + } + + [Test] + public void Live_settings_are_used_when_nothing_was_recorded_for_this_output() + { + var live = new PlayerNameSettings("/out/Game.apk", "1.0", 6, "IL2CPP"); + Assert.That(BuildArtifactChoice.BuildTimeSettings(null, "/out/Game.apk", live), Is.SameAs(live)); + var otherBuild = new PlayerNameSettings("/out/Other.apk", "1.0", 5, "Mono2x"); + Assert.That(BuildArtifactChoice.BuildTimeSettings(otherBuild, "/out/Game.apk", live), Is.SameAs(live)); + var noPath = new PlayerNameSettings(null, "1.0", 5, "IL2CPP"); + Assert.That(BuildArtifactChoice.BuildTimeSettings(noPath, null, live), Is.SameAs(live)); + } + + [Test] + public void An_exported_project_is_its_own_il2cpp_root_and_an_apk_uses_its_backup_folder_never_the_parent() + { + var backup = Path.Combine("/b", "Game_BackUpThisFolder_ButDontShipItWithYourGame"); + Assert.That(BuildArtifactChoice.Il2CppOutputRoot("/b/iOS", true), Is.EqualTo("/b/iOS")); + Assert.That(BuildArtifactChoice.Il2CppOutputRoot("/b/Game.apk", false), Is.EqualTo(backup)); + Assert.That(BuildArtifactChoice.Il2CppOutputRoot("/b/Game.aab", false), Is.EqualTo(backup)); + Assert.That(BuildArtifactChoice.Il2CppOutputRoot(null, false), Is.Null); + Assert.That(BuildArtifactChoice.Il2CppOutputRoot("", true), Is.Null); + } + + // Builds/Android is a Gradle export with its libil2cpp.so under jniLibs, Builds/iOS the Xcode export, and a + // Builds/Game.apk sits beside them. Each build's root must reach only its own native library and line map. + [Test] + public void A_sibling_builds_libil2cpp_and_line_map_are_outside_this_builds_root() + { + var builds = Path.Combine(Path.GetTempPath(), "bugsee-il2cpp-roots-" + Guid.NewGuid().ToString("N")); + try + { + var androidSo = Path.Combine(builds, "Android", "unityLibrary", "src", "main", "jniLibs", "arm64-v8a", "libil2cpp.so"); + var androidMap = Path.Combine(builds, "Android", "unityLibrary", "src", "main", "Il2CppOutputProject", "LineNumberMappings.json"); + var iosMap = Path.Combine(builds, "iOS", "Il2CppOutputProject", "LineNumberMappings.json"); + var apkMap = Path.Combine(builds, "Game_BackUpThisFolder_ButDontShipItWithYourGame", "il2cppOutput", "LineNumberMappings.json"); + foreach (var f in new[] { androidSo, androidMap, iosMap, apkMap }) + { + Directory.CreateDirectory(Path.GetDirectoryName(f)); + File.WriteAllText(f, "x"); + } + File.WriteAllText(Path.Combine(builds, "Game.apk"), "x"); + + var ios = BuildArtifactChoice.Il2CppOutputRoot(Path.Combine(builds, "iOS"), true); + Assert.That(Under(ios, "libil2cpp.so"), Is.Empty); + Assert.That(Under(ios, "LineNumberMappings.json"), Is.EqualTo(new[] { iosMap })); + + var apk = BuildArtifactChoice.Il2CppOutputRoot(Path.Combine(builds, "Game.apk"), false); + Assert.That(Under(apk, "libil2cpp.so"), Is.Empty); + Assert.That(Under(apk, "LineNumberMappings.json"), Is.EqualTo(new[] { apkMap })); + + var android = BuildArtifactChoice.Il2CppOutputRoot(Path.Combine(builds, "Android"), true); + Assert.That(Under(android, "libil2cpp.so"), Is.EqualTo(new[] { androidSo })); + Assert.That(Under(android, "LineNumberMappings.json"), Is.EqualTo(new[] { androidMap })); + } + finally + { + if (Directory.Exists(builds)) Directory.Delete(builds, true); + } + } + + static string[] Under(string root, string name) => + root != null && Directory.Exists(root) ? Directory.GetFiles(root, name, SearchOption.AllDirectories) : new string[0]; + + // The module-id lookup and the line-map lookup must search the same single folder, or one build's line map + // is uploaded under another build's module id. + [Test] + public void Module_id_and_line_map_lookups_use_the_same_il2cpp_root_and_never_the_parent() + { + var identity = EditorMethod("Editor/BugseeIl2CppModuleIdentity.cs", "static IEnumerable BuildRoots(BuildReport report)"); + var linemap = EditorMethod("Editor/BugseeIl2CppLinemapUpload.cs", "static string FindLineNumberMappings(BuildReport report)"); + foreach (var body in new[] { identity, linemap }) + { + Assert.That(body, Does.Contain("BuildArtifactChoice.Il2CppOutputRoot(")); + Assert.That(body, Does.Not.Contain("GetDirectoryName")); + } + } + + // The method's code with // comments removed, so prose cannot satisfy or break the assertions. + static string EditorMethod(string relative, string signature) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "package.json"))) + dir = Directory.GetParent(dir)?.FullName; + Assert.That(dir, Is.Not.Null.And.Not.Empty, "Could not locate repo root (package.json)."); + var source = File.ReadAllText(Path.Combine(dir, relative)); + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing method: " + signature); + var depth = 0; + for (var i = source.IndexOf('{', start); i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) + return Regex.Replace(source.Substring(start, i - start + 1), @"//[^\n]*", ""); + } + Assert.Fail("Unbalanced braces in " + relative); + return null; + } + } +} From 88d760cda8063ccf1fa87b673773bfac7a88dac4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:24:48 +0500 Subject: [PATCH 088/157] iOS: keep the wrapper channel across Stop; take Stop completion and status from the SDK. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problems in the iOS Stop/Launch lifecycle. - The wrapper channel was cleared on Stop and Delete. The SDK accepts and drops channel calls after stop, keeps the channel live, and never hands a second one to the same wrapper object (re-registering it is a no-op; only a different wrapper or nil makes it inert - BGSContracts.h, +setWrapper: docs). Clearing it silenced channel logs, network events and breadcrumbs for every later launch. The managed side already gates network events and breadcrumbs by launch phase; ChannelLog is not gated but the SDK drops it after stop. - Stop(completion) ran the completion straight after asking the SDK to stop. Native stop is asynchronous, so a Stop followed by Launch or Delete raced it. The completion now runs when the SDK reports teardown finished (BugseeStoppedBlock). There is one native stop at a time (StopWaitList): a Stop, or a Delete, that arrives while one is in flight in the same launch generation waits on it instead of issuing a nested [Bugsee stop:]. Waiters are matched to the native callback by a per-call token, not by order, so a lost or doubled callback cannot pair later completions with the wrong stop, and they run once. They are dropped (with a warning) if a Launch or Relaunch has bumped the launch generation since they were queued, the same fence the pending GDPR delete uses, so a completion from the stopped session never acts on the new one. The generation is read again before each waiter, so a Launch made by an earlier waiter (Stop(() => Launch(t)) followed by a Delete that joined the same stop) drops the rest; the native delete also checks that fence before its own [Bugsee stop:], so a stale delete never stops a newer session. The whole native callback is guarded, and each completion runs inside try/catch, because the callback is a reverse P/Invoke from an Objective-C block, where an unhandled exception is fatal under IL2CPP. Nothing is waited on when the SDK is already stopped. Measured with the real beta6 SDK in a simulator process: with the SDK never launched, status is 0 (Stopped) and [Bugsee stop:] still calls its block, so that check is an optimisation and the code does not depend on an undocumented behaviour. - Launch/Relaunch raced a Stop in flight (beta5 keeps a launch that completes after a stop stopped: "launch completed after a stop; staying stopped"). A Launch or Relaunch made while a native stop is in flight is now deferred until it finishes, without blocking. The latest request wins: a later Launch/Relaunch replaces a deferred one, a later Stop or Delete cancels it; when the stop finishes, its waiters run in arrival order, then the deferred launch, which is by construction the latest request, unless a waiter has made a newer one (a completion that launches or stops again). A cancelled launch is answered false, so nothing waits on it. - A stop the SDK does not report has two deadlines. Soft, 10 s after each Stop call (StopSoftDeadlineMs): that call's completion runs, or StopAsync faults with TimeoutException, with a warning, so no caller waits forever; the completion is held once by token, so the late report cannot run it again. The stop stays in flight, so a Launch made by that completion or later is still deferred and a further Stop still joins it instead of issuing a second native stop. Hard, 60 s after the native stop began (StopHardDeadlineMs): the bridge gives up on it, the remaining waiters (a Delete) and the deferred launch run with a warning, and a late report finds no token and is ignored. Residual race, stated in the code and accepted only after that long: if the native stop is in fact still running, it can stop the launch made then. - When the main thread does not take a Stop within 5 s (RunSyncLifecycle skips the body), StopAsync faults with TimeoutException and nothing is armed, since nothing is in flight. The public Stop completion is dropped with an error logged, not run: it cannot signal failure, and Stop(() => Launch(t)) would launch over a session that was never stopped. The Stop XML doc says so. - New Task-returning calls on the public Bugsee class, the existing signatures untouched: LaunchAsync and RelaunchAsync complete with the SDK's answer (the started block of the BugseeOptions launch/relaunch variants, passed back by token; the dictionary goes through +[BugseeOptions optionsFrom:]; plain Launch/Relaunch keep the old SDK calls) or false when the launch was cancelled; a 10 s fallback takes the SDK status as the answer if the block never comes. StopAsync completes when the stop is over, even when a Launch follows and drops the ordinary Stop completions, and faults with TimeoutException if the main thread did not take the call. Nothing blocks; results are set on the main thread. The bridge side is the internal ILifecycleResultBridge, which only the iOS bridge implements here; for the others (Android, Editor) the facade falls back to the plain call and the status read right after it. - IsLaunched/Status ignored the SDK: the managed flag was set unconditionally, so Launching/Stopping never showed and a refused launch read as launched. GetStatus now reads Bugsee.status directly (0..3 = Stopped, Launching, Launched, Stopping, append-only in the header), and the managed _launched flag is gone, since nothing reads it any more. Behaviour change: IsLaunched is true only in the SDK's Launched state, so it is false during the short Launching window; the only in-repo caller is the sample's status text. - DeleteCollectedDataOnDevice has one path now: everything runs in the main-thread lifecycle closure and the managed hooks are torn down unconditionally (idempotent) - but only in the launch generation the delete was requested in: a Launch that ran before the closure (queued ahead of it, or while a background caller waited) owns the SDK, and the closure now skips the teardown and its native stop instead of stopping that session (DeleteCollectedDataLaunchGeneration.RunIfCurrent). Before, it chose its path on the launched test, which is false while Launching, and a launch the SDK refused leaves the hooks installed with status Stopped, so either window left them over a stopped SDK; reading the SDK's status off the main thread was also racy. The delete waits on the shared native stop (joining a Stop in flight). The native delete only stops an SDK that is not already stopped, so the wipe runs synchronously before a deferred launch that follows it. (The Android bridge gets the matching status-based stop in the completion-callbacks PR.) - The no-SDK stub follows the new signatures. (Its separate typedef bug is fixed in the lock PR.) Not verified on a device: the full Stop/Launch/Delete sequences in an app. The bridge tests now drive the real beta5 SDK in a simulator process: launch and relaunch answer through the started block with the caller's token, stop completes with its token. The Runtime compiles against the Unity 2021.3 managed assemblies with UNITY_IOS. The source test that required clearing the channel on Stop now asserts the opposite, with the reason, plus the token calls, the status source, the guarded callbacks, the shared stop, the deferred launch, the give-up timer and the single-path Delete; StopWaitList has unit tests for joining, the generation fence, run-once, the deferred launch and its latest-wins rule, the stop staying in flight before the hard deadline, and giving up on an unreported stop; OneShotCallbacks for the token-matched launch answers and the once-only Stop completion; the delete fence for a generation that went stale before the closure ran. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Plugins/iOS/BugseeUnityBridge.mm | 81 +++- Plugins/iOS/BugseeUnityCallbacks.mm | 6 - Runtime/Bugsee.cs | 75 ++++ Runtime/Platform/ILifecycleResultBridge.cs | 23 + .../Platform/ILifecycleResultBridge.cs.meta | 11 + Runtime/Platform/IOS/IOSBridge.cs | 400 +++++++++++++++--- .../DeleteCollectedDataLaunchGeneration.cs | 12 + Runtime/WrapperPolicy/OneShotCallbacks.cs | 38 ++ .../WrapperPolicy/OneShotCallbacks.cs.meta | 11 + Runtime/WrapperPolicy/StopWaitList.cs | 168 ++++++++ Runtime/WrapperPolicy/StopWaitList.cs.meta | 11 + .../NetworkEventLaunchBufferTests.cs | 23 + Tests~/WrapperPolicy/OneShotCallbacksTests.cs | 54 +++ .../WrapperPolicy/OptionPlatformGateTests.cs | 4 +- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 142 ++++++- Tests~/WrapperPolicy/StopWaitListTests.cs | 275 ++++++++++++ Tests~/iOS/BridgeTests.mm | 43 ++ 17 files changed, 1279 insertions(+), 98 deletions(-) create mode 100644 Runtime/Platform/ILifecycleResultBridge.cs create mode 100644 Runtime/Platform/ILifecycleResultBridge.cs.meta create mode 100644 Runtime/WrapperPolicy/OneShotCallbacks.cs create mode 100644 Runtime/WrapperPolicy/OneShotCallbacks.cs.meta create mode 100644 Runtime/WrapperPolicy/StopWaitList.cs create mode 100644 Runtime/WrapperPolicy/StopWaitList.cs.meta create mode 100644 Tests~/WrapperPolicy/OneShotCallbacksTests.cs create mode 100644 Tests~/WrapperPolicy/StopWaitListTests.cs diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index b898894..a6433f5 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -197,7 +197,18 @@ static void BugseeBridgeApplyExtendedReportDict(BugseeExtendedReport *report, NS extern "C" { -void _bugsee_launch(const char *appToken, const char *optionsJson) +typedef void (*BugseeLaunchStartedFn)(int token, int success); + +static BugseeStartedBlock BugseeStartedBlockFor(int token, BugseeLaunchStartedFn started) +{ + return ^(BOOL success) { + started(token, success ? 1 : 0); + }; +} + +// With a started callback (the Task-returning launch), the SDK's answer goes back with the caller's token; the +// dictionary goes through +[BugseeOptions optionsFrom:] because only the BugseeOptions variant takes a block. +void _bugsee_launch(const char *appToken, const char *optionsJson, int startedToken, BugseeLaunchStartedFn started) { NSDictionary *dict = nil; if (optionsJson) { @@ -206,10 +217,16 @@ void _bugsee_launch(const char *appToken, const char *optionsJson) dict = parsed; } } - [Bugsee launchWithToken:BugseeNSString(appToken) andOptions:dict]; + if (!started) { + [Bugsee launchWithToken:BugseeNSString(appToken) andOptions:dict]; + return; + } + [Bugsee launchWithToken:BugseeNSString(appToken) + options:[BugseeOptions optionsFrom:dict] + started:BugseeStartedBlockFor(startedToken, started)]; } -void _bugsee_relaunch(const char *optionsJson) +void _bugsee_relaunch(const char *optionsJson, int startedToken, BugseeLaunchStartedFn started) { NSDictionary *dict = nil; if (optionsJson) { @@ -218,17 +235,32 @@ void _bugsee_relaunch(const char *optionsJson) dict = parsed; } } - [Bugsee relaunchWithDictionaryOptions:dict]; + if (!started) { + [Bugsee relaunchWithDictionaryOptions:dict]; + return; + } + [Bugsee relaunchWithOptions:[BugseeOptions optionsFrom:dict] started:BugseeStartedBlockFor(startedToken, started)]; } -void _bugsee_stop(void) +typedef void (*BugseeStopCompletionFn)(int token); + +// Stop is asynchronous; the completion runs once teardown has finished, with the caller's token so the +// managed side can match it to the right Stop call. +void _bugsee_stop(int token, BugseeStopCompletionFn completion) { - [Bugsee stop:nil]; + if (!completion) { + [Bugsee stop:nil]; + return; + } + [Bugsee stop:^{ + completion(token); + }]; } -bool _bugsee_get_launched(void) +// BugseeStatus raw values: Stopped 0, Launching 1, Launched 2, Stopping 3. +int _bugsee_get_status(void) { - return [Bugsee sharedInstance] != nil; + return (int)[Bugsee sharedInstance].status; } void _bugsee_start_blackout(void) @@ -519,13 +551,23 @@ void _bugsee_free(char *ptr) void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) { BugseeRunOnMain(^{ + BOOL (^allowed)(void) = ^BOOL { + return !shouldRun || shouldRun(capturedGeneration); + }; void (^runDeleteIfAllowed)(void) = ^{ - if (shouldRun && !shouldRun(capturedGeneration)) { + if (!allowed()) { return; } [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; }; - if ([Bugsee sharedInstance] != nil) { + // A Launch since the delete was requested owns the SDK now: do not stop it. + if (!allowed()) { + return; + } + // Only a running SDK is stopped: on a stopped one the stop's block may land after a launch that follows + // this call on the main thread, and the wipe would then be skipped. + Bugsee *instance = [Bugsee sharedInstance]; + if (instance != nil && instance.status != BugseeStatusStopped) { [Bugsee stop:^{ runDeleteIfAllowed(); }]; @@ -606,10 +648,21 @@ void _bugsee_upload_managed_report(const char *reportJson, extern "C" { -void _bugsee_launch(const char *appToken, const char *optionsJson) { (void)appToken; (void)optionsJson; } -void _bugsee_relaunch(const char *optionsJson) { (void)optionsJson; } -void _bugsee_stop(void) {} -bool _bugsee_get_launched(void) { return false; } +typedef void (*BugseeLaunchStartedFn)(int token, int success); +void _bugsee_launch(const char *appToken, const char *optionsJson, int startedToken, BugseeLaunchStartedFn started) +{ + (void)appToken; + (void)optionsJson; + if (started) started(startedToken, 0); +} +void _bugsee_relaunch(const char *optionsJson, int startedToken, BugseeLaunchStartedFn started) +{ + (void)optionsJson; + if (started) started(startedToken, 0); +} +typedef void (*BugseeStopCompletionFn)(int token); +void _bugsee_stop(int token, BugseeStopCompletionFn completion) { if (completion) completion(token); } +int _bugsee_get_status(void) { return 0; } void _bugsee_start_blackout(void) {} void _bugsee_end_blackout(void) {} void _bugsee_log(const char *message, int level) { (void)message; (void)level; } diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 894c850..1160f7f 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -477,11 +477,6 @@ void _bugsee_register_unity_callbacks(BugseeUnityFilterCb filterCb, gLifecycleCb = lifecycleCb; } -void _bugsee_clear_wrapper_channel(void) -{ - gChannel = nil; -} - void _bugsee_set_secure_buffer(int display, int *packed, int packedLength) { if (!packed || packedLength < 2) return; @@ -747,7 +742,6 @@ int _bugsee_test_wrapper_installed(void) extern "C" { void _bugsee_register_unity_callbacks(void *a, void *b, void *c) { (void)a; (void)b; (void)c; } -void _bugsee_clear_wrapper_channel(void) {} void _bugsee_channel_log(const char *message, int level, int source) { (void)message; (void)level; (void)source; } void _bugsee_channel_network(const char *eventJson, int requiresFiltering) { (void)eventJson; (void)requiresFiltering; } void _bugsee_channel_breadcrumb(const char *category, const char *message, int iosLevel, double timestampUnixSeconds) diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 7a3d88d..77520c6 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Feedback; @@ -64,8 +65,82 @@ public static void Launch(string appToken, Dictionary options = public static void Relaunch(Dictionary options = null) => Bridge.Relaunch(options); + /// + /// Stop Bugsee. runs once the SDK has finished stopping. It does not run if + /// a Launch/Relaunch starts a new session first, or if the stop itself did not run (on iOS, when called + /// off the main thread and the Unity main thread did not take it within 5 s; an error is logged). Use + /// to be told about that failure. + /// public static void Stop(Action completion = null) => Bridge.Stop(completion); + /// + /// that completes with the SDK's answer: true when it accepted the launch, false + /// when it refused it or the launch never ran (a later Launch, Relaunch, Stop or Delete cancelled it while + /// it waited for a stop in flight). Never blocks the calling thread; awaited from the Unity main thread, + /// the continuation resumes there. + /// + public static Task LaunchAsync(string appToken, Dictionary options = null) + { + if (string.IsNullOrEmpty(appToken)) + throw new ArgumentException("appToken is required", nameof(appToken)); + + MainThreadDispatcher.Ensure(); + var result = new TaskCompletionSource(); + if (Bridge is ILifecycleResultBridge bridge) + { + bridge.Launch(appToken, options, accepted => result.TrySetResult(accepted)); + } + else + { + Bridge.Launch(appToken, options); + result.TrySetResult(IsStartingOrLaunched(Bridge.GetStatus())); + } + return result.Task; + } + + /// that completes with the SDK's answer, as . + public static Task RelaunchAsync(Dictionary options = null) + { + var result = new TaskCompletionSource(); + if (Bridge is ILifecycleResultBridge bridge) + { + bridge.Relaunch(options, accepted => result.TrySetResult(accepted)); + } + else + { + Bridge.Relaunch(options); + result.TrySetResult(IsStartingOrLaunched(Bridge.GetStatus())); + } + return result.Task; + } + + /// + /// that completes once the SDK has finished stopping. Unlike a Stop completion it + /// completes even when a Launch follows before it does. Faults with if the + /// stop could not be handed to the Unity main thread, or the SDK has not reported it within 10 s (a Launch + /// still waits for that stop). + /// + public static Task StopAsync() + { + var result = new TaskCompletionSource(); + if (Bridge is ILifecycleResultBridge bridge) + { + bridge.Stop(ran => + { + if (ran) result.TrySetResult(true); + else result.TrySetException(new TimeoutException("Stop did not finish in time.")); + }); + } + else + { + Bridge.Stop(() => result.TrySetResult(true)); + } + return result.Task; + } + + static bool IsStartingOrLaunched(BugseeStatus status) => + status == BugseeStatus.Launching || status == BugseeStatus.Launched; + public static bool IsLaunched => Bridge.GetLaunched(); public static BugseeStatus Status => Bridge.GetStatus(); diff --git a/Runtime/Platform/ILifecycleResultBridge.cs b/Runtime/Platform/ILifecycleResultBridge.cs new file mode 100644 index 0000000..2618461 --- /dev/null +++ b/Runtime/Platform/ILifecycleResultBridge.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.Platform +{ + /// + /// Optional bridge surface behind the Task-returning lifecycle calls. A bridge without it gets a fallback in + /// the facade (the launch result read from the status right after the call returns). + /// + internal interface ILifecycleResultBridge + { + /// gets the SDK's answer: false when it refused the launch, or when + /// the launch never ran (a later Stop or Launch cancelled it, or the main thread did not take it). + void Launch(string appToken, IDictionary options, Action started); + + /// + void Relaunch(IDictionary options, Action started); + + /// runs once, when the stop is over: true when the stop ran (even + /// if a later Launch dropped the ordinary Stop completions), false when the main thread did not take it. + void Stop(Action finished); + } +} diff --git a/Runtime/Platform/ILifecycleResultBridge.cs.meta b/Runtime/Platform/ILifecycleResultBridge.cs.meta new file mode 100644 index 0000000..a551af4 --- /dev/null +++ b/Runtime/Platform/ILifecycleResultBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7157ded334e24f0abcc4cc656488a3aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 1dc9190..d7d1531 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -22,16 +22,17 @@ namespace Bugsee.Platform.IOS /// Native crash reporting is owned by the iOS SDK when /// is enabled at Launch. /// - sealed class IOSBridge : IBugseeNativeBridge + sealed class IOSBridge : IBugseeNativeBridge, ILifecycleResultBridge { delegate void ManagedReportUploadNativeCallback(int succeeded, ulong uploadToken); delegate int DeleteCollectedDataShouldRunNativeCallback(int capturedGeneration); + delegate void StopNativeCallback(int token); + delegate void LaunchStartedNativeCallback(int token, int success); ulong _inFlightUploadToken; ulong _managedReportUploadFence; int _reportUploadGeneration; - bool _launched; bool _blackout; IosAppearance _appearance; IosReport _openReport; @@ -54,71 +55,335 @@ public IOSBridge() public void EnsureWrapperRegistered() => IosNativeCallbacks.EnsureWrapper(); - public void Launch(string appToken, IDictionary options) + public void Launch(string appToken, IDictionary options) => Launch(appToken, options, null); + + void ILifecycleResultBridge.Launch(string appToken, IDictionary options, Action started) => + Launch(appToken, options, started); + + void Launch(string appToken, IDictionary options, Action started) { ManagedExceptionPayload.EnsureBuildIdentity(); if (!MainThreadDispatcher.RunSyncLifecycle(() => - { - EnsureWrapperRegistered(); - DeleteCollectedDataLaunchGeneration.BumpForLaunch(); - _networkLaunchBuffer.BeginNewLaunchCycle(); - _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); - _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options))); - _launched = true; - ExceptionPipeline.Install(this, options); - HostLogForwarder.InstallOnce(this); - })) + LaunchOrDefer("Launch", () => LaunchNow(appToken, options, started), started))) { Debug.LogError("[Bugsee] Launch timed out waiting for the Unity main thread."); + RunResult(started, false); } } - public void Relaunch(IDictionary options) + public void Relaunch(IDictionary options) => Relaunch(options, null); + + void ILifecycleResultBridge.Relaunch(IDictionary options, Action started) => + Relaunch(options, started); + + void Relaunch(IDictionary options, Action started) { if (!MainThreadDispatcher.RunSyncLifecycle(() => - { - DeleteCollectedDataLaunchGeneration.BumpForLaunch(); - _networkLaunchBuffer.BeginNewLaunchCycle(); - _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); - _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options))); - ExceptionPipeline.Install(this, options); - HostLogForwarder.InstallOnce(this); - })) + LaunchOrDefer("Relaunch", () => RelaunchNow(options, started), started))) { Debug.LogError("[Bugsee] Relaunch timed out waiting for the Unity main thread."); + RunResult(started, false); } } - public void Stop(Action completion = null) + // Main thread only. A launch made while a native stop is in flight would race it (the SDK keeps a launch + // that completes after a stop stopped), so it waits for that stop; the latest Launch/Relaunch/Stop/Delete + // request wins (StopWaitList). + void LaunchOrDefer(string name, Action launchNow, Action started) + { + var deferred = StopWaiters.TryDeferLaunch(run => + { + if (run) + { + launchNow(); + return; + } + Debug.Log("[Bugsee] " + name + " was cancelled by a later Launch, Relaunch, Stop or Delete."); + RunResult(started, false); + }, out var cancelled); + cancelled?.Invoke(false); + if (deferred) + { + Debug.Log("[Bugsee] " + name + " waits for the stop in flight to finish."); + return; + } + launchNow(); + } + + void LaunchNow(string appToken, IDictionary options, Action started) + { + EnsureWrapperRegistered(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + _networkLaunchBuffer.BeginNewLaunchCycle(); + _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + var startedToken = RegisterLaunchResult(started); + _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options)), + startedToken, startedToken == 0 ? null : (LaunchStartedNativeCallback)OnNativeStarted); + ExceptionPipeline.Install(this, options); + HostLogForwarder.InstallOnce(this); + } + + void RelaunchNow(IDictionary options, Action started) + { + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + _networkLaunchBuffer.BeginNewLaunchCycle(); + _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + var startedToken = RegisterLaunchResult(started); + _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options)), + startedToken, startedToken == 0 ? null : (LaunchStartedNativeCallback)OnNativeStarted); + ExceptionPipeline.Install(this, options); + HostLogForwarder.InstallOnce(this); + } + + public void Stop(Action completion = null) => Stop(completion, null); + + void ILifecycleResultBridge.Stop(Action finished) => Stop(null, finished); + + // completion is the public Stop's: dropped if a Launch follows before it runs. finished (StopAsync) runs + // either way, so nothing awaits forever. + void Stop(Action completion, Action finished) { if (!MainThreadDispatcher.RunSyncLifecycle(() => { HostLogForwarder.Uninstall(); ExceptionPipeline.Uninstall(); - _bugsee_clear_wrapper_channel(); - _bugsee_stop(); - _launched = false; CancelManagedReportUpload(); _openReport?.ReleaseSnapshotFiles(); _openReport = null; _openReportHandle = null; _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - completion?.Invoke(); + + // The wrapper channel is deliberately kept: the SDK drops calls made after stop + // and re-registering the same wrapper never hands out a new one, so clearing it + // here would silence the channel for every later launch. + StopWithSoftDeadline(completion, finished); })) { - Debug.LogError("[Bugsee] Stop timed out waiting for the Unity main thread."); + // The body was skipped, so nothing is in flight and no deadline is armed. StopAsync is told it + // failed. The public completion is dropped, not run: it cannot tell success from failure, and + // one that relaunches (Stop(() => Launch(t))) would act on a session that was never stopped. + if (finished != null) + { + Debug.LogError("[Bugsee] Stop timed out waiting for the Unity main thread; it did not run."); + RunResult(finished, false); + } + else + { + Debug.LogError("[Bugsee] Stop timed out waiting for the Unity main thread; it did not run" + + (completion != null ? " and its completion was dropped." : ".")); + } + } + } + + // One native stop at a time: a Stop or Delete while one is in flight waits on it rather than issuing a + // nested [Bugsee stop:], and a Launch or Relaunch waits for it to finish. Waiters are matched to the + // native callback by token, and dropped if a Launch or Relaunch has started a new session since (same + // fence as the pending GDPR delete). + // + // Two deadlines for a stop the SDK does not report: + // - soft (StopSoftDeadlineMs after each Stop call): that call's completion runs, or StopAsync faults, with + // a warning, so no caller waits forever. The stop stays in flight: a Launch made by that completion or + // later is still deferred, and a further Stop still joins instead of issuing a second native stop. + // - hard (StopHardDeadlineMs after the native stop began): the bridge gives up on the stop. The remaining + // waiters (a Delete) and the deferred launch run with a warning, and a late report is ignored by token. + // Residual race, accepted only after this long: if the native stop is in fact still running, it can + // stop the launch made now (beta5 keeps a launch that completes after a stop stopped). + static readonly StopWaitList StopWaiters = new StopWaitList(); + static readonly OneShotCallbacks LaunchResults = new OneShotCallbacks(); + static readonly OneShotCallbacks StopCompletions = new OneShotCallbacks(); + const int StopSoftDeadlineMs = 10000; + const int StopHardDeadlineMs = 60000; + const int LaunchResultTimeoutMs = 10000; + + // Main thread only. The caller's completion is held once, by token, so the soft deadline and the SDK's + // report cannot both run it: true from the report, false from the deadline. + void StopWithSoftDeadline(Action completion, Action finished) + { + if (completion == null && finished == null) + { + StopNativeThen(null); + return; } + + var token = StopCompletions.Register(stopped => + { + if (finished != null) RunResult(finished, stopped); + else RunCompletion(completion); + }); + Action run = () => + { + if (StopCompletions.TryTake(token, out var callback)) callback(true); + }; + // Dropped (a Launch came first): the public completion is not run; StopAsync still completes. + Action dropped = () => + { + if (StopCompletions.TryTake(token, out var callback) && finished != null) callback(true); + }; + if (StopNativeThen(run, dropped)) ArmStopSoftDeadline(token); } - public bool GetLaunched() => _launched && _bugsee_get_launched(); + static void ArmStopSoftDeadline(int token) + { + AfterOnMainThread(StopSoftDeadlineMs, () => + { + if (!StopCompletions.TryTake(token, out var callback)) return; + Debug.LogWarning("[Bugsee] The SDK has not reported that stop finished after " + + StopSoftDeadlineMs / 1000 + " s; running the Stop completion now. Launch still waits for the stop."); + callback(false); + }); + } + + // Main thread only (inside a lifecycle closure). True when then waits on a native stop in flight; false + // when it has already run. + bool StopNativeThen(Action then, Action dropped = null) + { + var generation = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + var joined = StopWaiters.TryJoin(then, generation, out var cancelledLaunch, dropped); + cancelledLaunch?.Invoke(false); + if (joined) return true; + // Already stopped: nothing will be reported, so run now. ([Bugsee stop:] on a stopped SDK still + // calls its block, measured on beta6; this only avoids depending on that.) + if (GetStatus() == BugseeStatus.Stopped) + { + _bugsee_stop(0, null); + RunCompletion(then); + return false; + } - public BugseeStatus GetStatus() => - GetLaunched() ? BugseeStatus.Launched : BugseeStatus.Stopped; + var token = StopWaiters.Begin(then, generation, dropped); + GiveUpIfStopNotReported(token); + _bugsee_stop(token, OnNativeStopped); + return true; + } + + static void GiveUpIfStopNotReported(int token) + { + AfterOnMainThread(StopHardDeadlineMs, () => + { + if (!StopWaiters.IsPending(token)) return; + Debug.LogWarning("[Bugsee] The SDK has not reported that stop finished after " + + StopHardDeadlineMs / 1000 + " s; giving up on it and running what waits for it."); + CompleteStop(token); + }); + } + + static void AfterOnMainThread(int delayMs, Action action) + { + var timer = default(System.Threading.Timer); + timer = new System.Threading.Timer(_ => + { + try + { + MainThreadDispatcher.Run(() => + { + try { action(); } + catch (Exception ex) { Debug.LogException(ex); } + }); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + finally + { + timer?.Dispose(); + } + }, null, delayMs, System.Threading.Timeout.Infinite); + } + + // Main thread only. + static void CompleteStop(int token) + { + // The generation is read again before each waiter: a completion that relaunches (the documented + // Stop(() => Launch(...))) must not have its new session stopped by a Delete that joined the same stop. + if (StopWaiters.Complete( + token, DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete, RunCompletion)) + Debug.LogWarning("[Bugsee] Stop finished after a later Launch; its remaining completions were dropped."); + } + + // The user's completion must never throw out of here: this runs inside a reverse P/Invoke from an + // Objective-C block, where an unhandled managed exception is fatal under IL2CPP. + static void RunCompletion(Action completion) + { + if (completion == null) return; + try { completion(); } + catch (Exception ex) { Debug.LogException(ex); } + } + + static void RunResult(Action callback, bool value) + { + if (callback == null) return; + try { callback(value); } + catch (Exception ex) { Debug.LogException(ex); } + } + + // Native stop is asynchronous; run the caller's completion once teardown has finished. + [MonoPInvokeCallback(typeof(StopNativeCallback))] + static void OnNativeStopped(int token) + { + // Nothing may escape a reverse P/Invoke from an Objective-C block (fatal under IL2CPP), including + // the dispatcher's own setup. + try + { + // Settled on the main thread, where Launch bumps the generation, so a Launch queued ahead of + // this report is seen. + MainThreadDispatcher.Run(() => CompleteStop(token)); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + } + + // 0 when nobody waits for the result, so the plain Launch keeps the SDK call without a started block. + // If the SDK never answers, the result is read from its status at the deadline. + int RegisterLaunchResult(Action started) + { + if (started == null) return 0; + var token = LaunchResults.Register(started); + AfterOnMainThread(LaunchResultTimeoutMs, () => + { + if (!LaunchResults.TryTake(token, out var callback)) return; + var status = GetStatus(); + Debug.LogWarning("[Bugsee] The SDK has not answered the launch after " + + LaunchResultTimeoutMs / 1000 + " s; taking its status (" + status + ") as the answer."); + RunResult(callback, status == BugseeStatus.Launching || status == BugseeStatus.Launched); + }); + return token; + } + + [MonoPInvokeCallback(typeof(LaunchStartedNativeCallback))] + static void OnNativeStarted(int token, int success) + { + try + { + MainThreadDispatcher.Run(() => + { + if (LaunchResults.TryTake(token, out var callback)) RunResult(callback, success != 0); + }); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + } + + public bool GetLaunched() => GetStatus() == BugseeStatus.Launched; + + // The SDK owns the state, including Launching and Stopping, so a launch it refused reads as Stopped + // and so does anything before the first Launch (a nil instance answers 0). + public BugseeStatus GetStatus() + { + var native = _bugsee_get_status(); + return native >= 0 && native <= (int)BugseeStatus.Stopping + ? (BugseeStatus)native + : BugseeStatus.Stopped; + } public void StartBlackout() { @@ -162,36 +427,34 @@ public void NotifyLifecycle(string eventType) public void DeleteCollectedDataOnDevice() { var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - if (GetLaunched()) - { - if (!MainThreadDispatcher.RunSyncLifecycle(() => - { - HostLogForwarder.Uninstall(); - ExceptionPipeline.Uninstall(); - _bugsee_clear_wrapper_channel(); - CancelManagedReportUpload(); - _openReport?.ReleaseSnapshotFiles(); - _openReport = null; - _openReportHandle = null; - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _launched = false; - _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); - })) + // One path, decided inside the main-thread closure and not on the SDK's state: Launch always installs + // the managed hooks, even when the SDK then refuses it (status stays Stopped), so they are torn down + // unconditionally (uninstalling is idempotent), and the SDK's status is not read off-thread. The + // delete waits on the shared native stop (joining one already in flight); the native delete only + // stops an SDK that is not already stopped. + if (!MainThreadDispatcher.RunSyncLifecycle(() => { - Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); - } - - return; + // A Launch/Relaunch since the delete was requested (queued ahead of this closure, or on the + // main thread while a background caller waited) owns the SDK now: tearing it down would stop + // the new session, and the wipe would be skipped anyway. + if (!DeleteCollectedDataLaunchGeneration.RunIfCurrent(deleteGeneration, () => + { + HostLogForwarder.Uninstall(); + ExceptionPipeline.Uninstall(); + CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + StopNativeThen(() => + _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative)); + })) + Debug.LogWarning("[Bugsee] DeleteCollectedDataOnDevice was overtaken by a later Launch; nothing was deleted."); + })) + { + Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); } - - CancelManagedReportUpload(); - _openReport?.ReleaseSnapshotFiles(); - _openReport = null; - _openReportHandle = null; - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); } [MonoPInvokeCallback(typeof(DeleteCollectedDataShouldRunNativeCallback))] @@ -551,11 +814,12 @@ static string JsonString(string s) return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r") + "\""; } - [DllImport("__Internal")] static extern void _bugsee_launch(string appToken, string optionsJson); - [DllImport("__Internal")] static extern void _bugsee_relaunch(string optionsJson); - [DllImport("__Internal")] static extern void _bugsee_stop(); - [DllImport("__Internal")] static extern void _bugsee_clear_wrapper_channel(); - [DllImport("__Internal")] static extern bool _bugsee_get_launched(); + [DllImport("__Internal")] static extern void _bugsee_launch( + string appToken, string optionsJson, int startedToken, LaunchStartedNativeCallback started); + [DllImport("__Internal")] static extern void _bugsee_relaunch( + string optionsJson, int startedToken, LaunchStartedNativeCallback started); + [DllImport("__Internal")] static extern void _bugsee_stop(int token, StopNativeCallback completion); + [DllImport("__Internal")] static extern int _bugsee_get_status(); [DllImport("__Internal")] static extern void _bugsee_start_blackout(); [DllImport("__Internal")] static extern void _bugsee_end_blackout(); [DllImport("__Internal")] static extern void _bugsee_log(string message, int level); diff --git a/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs index c529976..e7b43e9 100644 --- a/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs +++ b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; namespace Bugsee.WrapperPolicy @@ -15,5 +16,16 @@ internal static class DeleteCollectedDataLaunchGeneration public static bool ShouldRunDelete(int capturedGeneration) => capturedGeneration == Volatile.Read(ref _generation); + + /// + /// Runs a delete's teardown only if no Launch/Relaunch has happened since it was requested; false when it + /// was skipped. Main thread, where launches bump the generation, so the check and the body are not split. + /// + public static bool RunIfCurrent(int capturedGeneration, Action body) + { + if (!ShouldRunDelete(capturedGeneration)) return false; + body(); + return true; + } } } diff --git a/Runtime/WrapperPolicy/OneShotCallbacks.cs b/Runtime/WrapperPolicy/OneShotCallbacks.cs new file mode 100644 index 0000000..ac5761d --- /dev/null +++ b/Runtime/WrapperPolicy/OneShotCallbacks.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Callbacks handed to native code by an integer token. Each one is taken at most once, so whichever comes + /// first, the SDK's answer or the bridge's timeout, wins and the other finds nothing. + /// + internal sealed class OneShotCallbacks + { + readonly object _gate = new object(); + readonly Dictionary> _callbacks = new Dictionary>(); + int _lastToken; + + /// Returns the token for ; never 0, which stands for "no callback". + public int Register(Action callback) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + lock (_gate) + { + do { _lastToken++; } while (_lastToken == 0 || _callbacks.ContainsKey(_lastToken)); + _callbacks[_lastToken] = callback; + return _lastToken; + } + } + + public bool TryTake(int token, out Action callback) + { + lock (_gate) + { + if (!_callbacks.TryGetValue(token, out callback)) return false; + _callbacks.Remove(token); + return true; + } + } + } +} diff --git a/Runtime/WrapperPolicy/OneShotCallbacks.cs.meta b/Runtime/WrapperPolicy/OneShotCallbacks.cs.meta new file mode 100644 index 0000000..4ccfdba --- /dev/null +++ b/Runtime/WrapperPolicy/OneShotCallbacks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d2efd8bb80504e87a0f8a8f625c91962 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/StopWaitList.cs b/Runtime/WrapperPolicy/StopWaitList.cs new file mode 100644 index 0000000..cbcbe21 --- /dev/null +++ b/Runtime/WrapperPolicy/StopWaitList.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// + /// One native stop at a time, and the actions waiting for it to finish. + /// A Stop or Delete that arrives while a native stop of the same launch generation is in flight waits on + /// that stop instead of issuing another one. Waiters run when the SDK reports that stop finished (or the + /// bridge gives up waiting for the report), matched by token, not by order. They are dropped if a + /// Launch/Relaunch has started a new session since they were queued, including one made by an earlier + /// waiter: they belong to the session that stopped and must not act on the new one. + /// + /// A Launch/Relaunch that arrives while a native stop is in flight is deferred until it finishes, because + /// the SDK keeps a launch that completes after a stop stopped. The latest request wins: a later + /// Launch/Relaunch replaces a deferred one, and a later Stop or Delete cancels it. When the stop finishes the + /// waiters run in arrival order, then the deferred launch, which is by construction the latest request; it + /// is cancelled instead if a waiter has made a newer request meanwhile (a completion that launches or stops + /// again was called later still). A cancelled launch is told so (false), so nothing waits on it. + /// + /// + internal sealed class StopWaitList + { + sealed class Waiter + { + public Action Run; + public Action Dropped; + } + + sealed class Pending + { + public int Generation; + public readonly List Waiters = new List(); + } + + readonly object _gate = new object(); + readonly Dictionary _pending = new Dictionary(); + int _lastToken; + int _inFlightToken; + // Bumped by every lifecycle request, so a deferred launch can tell whether it is still the latest one. + int _intent; + Action _deferredLaunch; + int _deferredLaunchIntent; + + /// + /// Queues on the native stop in flight in . + /// False when there is none; the caller then issues a native stop with . + /// , if any, runs in its place when the waiter is dropped. + /// A deferred launch is cancelled either way (it comes back in , for + /// the caller to call with false outside any lock): this request is newer. + /// + public bool TryJoin(Action waiter, int generation, out Action cancelledLaunch, Action dropped = null) + { + lock (_gate) + { + _intent++; + cancelledLaunch = TakeDeferredLaunch(); + if (_inFlightToken == 0 + || !_pending.TryGetValue(_inFlightToken, out var pending) + || pending.Generation != generation) + return false; + Add(pending, waiter, dropped); + return true; + } + } + + /// Records a new native stop in flight and returns the token to pass to it. + public int Begin(Action waiter, int generation, Action dropped = null) + { + lock (_gate) + { + _intent++; + var token = ++_lastToken; + var pending = new Pending { Generation = generation }; + Add(pending, waiter, dropped); + _pending[token] = pending; + _inFlightToken = token; + return token; + } + } + + /// + /// Defers until the native stop in flight finishes. False when none is in + /// flight: the caller launches now. A launch deferred earlier is replaced and comes back in + /// . is later called with true to + /// run it, or false when a newer request cancelled it. + /// + public bool TryDeferLaunch(Action launch, out Action cancelledLaunch) + { + lock (_gate) + { + _intent++; + cancelledLaunch = TakeDeferredLaunch(); + if (_inFlightToken == 0) return false; + _deferredLaunch = launch; + _deferredLaunchIntent = _intent; + return true; + } + } + + /// + /// Stop finished, or the caller has given up waiting for it: runs its waiters, + /// then any deferred launch, in order through , outside the lock. Nothing runs + /// for an unknown or already completed token, so a report that arrives after a timeout is ignored. + /// is read again before each waiter, so a Launch/Relaunch, whether + /// made before this call or by an earlier waiter, drops the rest (their dropped actions run + /// instead). Returns true when waiters were dropped. + /// + public bool Complete(int token, Func currentGeneration, Action run) + { + Pending pending; + Action launch = null; + int launchIntent = 0; + lock (_gate) + { + if (!_pending.TryGetValue(token, out pending)) return false; + _pending.Remove(token); + if (_inFlightToken == token) + { + _inFlightToken = 0; + launch = TakeDeferredLaunch(); + launchIntent = _deferredLaunchIntent; + } + } + + var dropped = false; + foreach (var waiter in pending.Waiters) + { + if (!dropped && currentGeneration() != pending.Generation) dropped = true; + var action = dropped ? waiter.Dropped : waiter.Run; + if (action != null) run(action); + } + + if (launch != null) + { + bool latest; + lock (_gate) + { + latest = _intent == launchIntent && _inFlightToken == 0; + } + run(() => launch(latest)); + } + return dropped; + } + + /// True while stop has neither been reported nor given up on. + public bool IsPending(int token) + { + lock (_gate) + { + return _pending.ContainsKey(token); + } + } + + static void Add(Pending pending, Action waiter, Action dropped) + { + if (waiter != null || dropped != null) + pending.Waiters.Add(new Waiter { Run = waiter, Dropped = dropped }); + } + + Action TakeDeferredLaunch() + { + var launch = _deferredLaunch; + _deferredLaunch = null; + return launch; + } + } +} diff --git a/Runtime/WrapperPolicy/StopWaitList.cs.meta b/Runtime/WrapperPolicy/StopWaitList.cs.meta new file mode 100644 index 0000000..6127a94 --- /dev/null +++ b/Runtime/WrapperPolicy/StopWaitList.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 03a626b5df824096b8c98d8f99b4e721 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs index fccb2c9..6356e22 100644 --- a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Bugsee.WrapperPolicy; using NUnit.Framework; @@ -24,6 +25,28 @@ public void Delete_generation_runs_when_generation_matches_current() Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(afterLaunch), Is.True); } + [Test] + public void A_delete_whose_generation_went_stale_before_its_body_runs_does_nothing() + { + // Delete captured on a background thread, then a Launch runs on the main thread before the Delete's + // closure: the teardown (and its native stop) must not run against the new session. + var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + var ran = false; + Assert.That(DeleteCollectedDataLaunchGeneration.RunIfCurrent(captured, () => ran = true), Is.False); + Assert.That(ran, Is.False); + } + + [Test] + public void A_delete_in_its_own_generation_runs_its_body() + { + // Includes a launch the SDK refused in the same generation: its hooks still need tearing down. + var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + var ran = false; + Assert.That(DeleteCollectedDataLaunchGeneration.RunIfCurrent(captured, () => ran = true), Is.True); + Assert.That(ran, Is.True); + } + [Test] public void Breadcrumb_buffer_flush_preserves_insertion_order_after_launch() { diff --git a/Tests~/WrapperPolicy/OneShotCallbacksTests.cs b/Tests~/WrapperPolicy/OneShotCallbacksTests.cs new file mode 100644 index 0000000..898cb66 --- /dev/null +++ b/Tests~/WrapperPolicy/OneShotCallbacksTests.cs @@ -0,0 +1,54 @@ +using System; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class OneShotCallbacksTests + { + [Test] + public void A_callback_is_taken_once_by_its_token() + { + var callbacks = new OneShotCallbacks(); + var got = false; + var token = callbacks.Register(value => got = value); + Assert.That(token, Is.Not.EqualTo(0), "0 stands for no callback on the native side"); + + Assert.That(callbacks.TryTake(token, out var callback), Is.True); + callback(true); + Assert.That(got, Is.True); + Assert.That(callbacks.TryTake(token, out _), Is.False, "the SDK's answer and the timeout cannot both run it"); + } + + [Test] + public void Tokens_are_distinct_and_unknown_ones_find_nothing() + { + var callbacks = new OneShotCallbacks(); + var a = callbacks.Register(_ => { }); + var b = callbacks.Register(_ => { }); + Assert.That(a, Is.Not.EqualTo(b)); + Assert.That(callbacks.TryTake(b + 100, out _), Is.False); + Assert.That(callbacks.TryTake(a, out _), Is.True); + Assert.That(callbacks.TryTake(b, out _), Is.True); + } + + [Test] + public void A_stop_completion_runs_once_whether_the_soft_deadline_or_the_report_comes_first() + { + var callbacks = new OneShotCallbacks(); + var answers = new System.Collections.Generic.List(); + var token = callbacks.Register(answers.Add); + + // Soft deadline first: the caller is answered (false = not reported yet)... + if (callbacks.TryTake(token, out var early)) early(false); + // ...and the SDK's late report, or a drop by a later Launch, finds nothing. + if (callbacks.TryTake(token, out var late)) late(true); + Assert.That(answers, Is.EqualTo(new[] { false })); + } + + [Test] + public void A_null_callback_is_rejected() + { + Assert.Throws(() => new OneShotCallbacks().Register(null)); + } + } +} diff --git a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs index 37d3250..e0d7a30 100644 --- a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs +++ b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs @@ -147,11 +147,11 @@ public void IosBridge_launch_and_relaunch_call_ForIos_before_json() { string text = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); Assert.That( - CountOccurrences(text, "_bugsee_launch(appToken, " + ForIosJsonCall + ")"), + CountOccurrences(text, "_bugsee_launch(appToken, " + ForIosJsonCall + ","), Is.EqualTo(1), "Launch must JSON-encode OptionPlatformGate.ForIos(options)"); Assert.That( - CountOccurrences(text, "_bugsee_relaunch(" + ForIosJsonCall + ")"), + CountOccurrences(text, "_bugsee_relaunch(" + ForIosJsonCall + ","), Is.EqualTo(1), "Relaunch must JSON-encode OptionPlatformGate.ForIos(options)"); } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 6bdaa0e..65866b1 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -94,6 +94,47 @@ public void Ios_delete_collected_data_body_uses_main_thread_hop() string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); Assert.That(body, Does.Contain("BugseeRunOnMain")); Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); + // A delete made stale by a Launch must not stop the new session, so the check precedes the stop. + int check = body.IndexOf("if (!allowed())", StringComparison.Ordinal); + int stop = body.IndexOf("[Bugsee stop:", StringComparison.Ordinal); + Assert.That(check, Is.GreaterThanOrEqualTo(0)); + Assert.That(stop, Is.GreaterThan(check)); + // Only a running SDK is stopped, so the wipe on an already stopped one runs before a launch that follows. + Assert.That(body, Does.Contain("instance.status != BugseeStatusStopped")); + } + + [Test] + public void Ios_launch_passes_the_started_answer_back_by_token() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string launch = ExtractNativeFunctionBody(bridge, "_bugsee_launch"); + Assert.That(launch, Does.Contain("launchWithToken:BugseeNSString(appToken) andOptions:dict")); + Assert.That(launch, Does.Contain("started:BugseeStartedBlockFor(startedToken, started)")); + string relaunch = ExtractNativeFunctionBody(bridge, "_bugsee_relaunch"); + Assert.That(relaunch, Does.Contain("relaunchWithDictionaryOptions:dict")); + Assert.That(relaunch, Does.Contain("started:BugseeStartedBlockFor(startedToken, started)")); + } + + [Test] + public void Facade_async_lifecycle_uses_the_result_bridge_and_never_blocks() + { + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + string launch = ExtractMethodBody(facade, "public static Task LaunchAsync(string appToken, Dictionary options = null)"); + Assert.That(launch, Does.Contain("bridge.Launch(appToken, options, accepted => result.TrySetResult(accepted))")); + string relaunch = ExtractMethodBody(facade, "public static Task RelaunchAsync(Dictionary options = null)"); + Assert.That(relaunch, Does.Contain("bridge.Relaunch(options, accepted => result.TrySetResult(accepted))")); + string stop = ExtractMethodBody(facade, "public static Task StopAsync()"); + Assert.That(stop, Does.Contain("bridge.Stop(")); + Assert.That(stop, Does.Contain("TimeoutException")); + foreach (var body in new[] { launch, relaunch, stop }) + { + Assert.That(body, Does.Not.Contain(".Wait(")); + Assert.That(body, Does.Not.Contain(".Result")); + } + // The existing signatures stay. + Assert.That(facade, Does.Contain("public static void Launch(string appToken, Dictionary options = null)")); + Assert.That(facade, Does.Contain("public static void Relaunch(Dictionary options = null)")); + Assert.That(facade, Does.Contain("public static void Stop(Action completion = null)")); } [Test] @@ -405,7 +446,7 @@ public void Launch_and_relaunch_bump_delete_generation_before_native_call() Assert.That(androidRelaunch, Does.Contain("BumpForLaunch()")); string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); - string iosLaunch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); + string iosLaunch = ExtractMethodBody(ios, "void LaunchNow(string appToken, IDictionary options, Action started)"); Assert.That(iosLaunch, Does.Contain("BumpForLaunch()")); Assert.That(iosLaunch, Does.Contain("BeginNewLaunchCycle()")); Assert.That(iosLaunch, Does.Contain("_bugsee_launch")); @@ -456,13 +497,98 @@ public void Ios_network_buffer_wiring() Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhaseFromLifecycle")); Assert.That(notify, Does.Contain("_networkLaunchBuffer.SetPhaseFromLifecycle")); - string launch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); - Assert.That(launch, Does.Contain("SetPhase(NetworkLaunchPhase.BeforeLaunched)")); - - string stop = ExtractMethodBody(ios, "public void Stop(Action completion = null)"); - Assert.That(stop, Does.Contain("_bugsee_clear_wrapper_channel")); - Assert.That(stop, Does.Not.Contain("_bugsee_clear_bridge_wrapper_channel")); - Assert.That(stop, Does.Contain("SetPhase(NetworkLaunchPhase.Stopped)")); + string launchNow = ExtractMethodBody(ios, "void LaunchNow(string appToken, IDictionary options, Action started)"); + Assert.That(launchNow, Does.Contain("SetPhase(NetworkLaunchPhase.BeforeLaunched)")); + Assert.That(launchNow, Does.Contain("RegisterLaunchResult(started)")); + + // Launch and Relaunch wait for a native stop in flight instead of racing it. + string launch = ExtractMethodBody(ios, "void Launch(string appToken, IDictionary options, Action started)"); + Assert.That(launch, Does.Contain("LaunchOrDefer(\"Launch\", () => LaunchNow(appToken, options, started), started)")); + string relaunch = ExtractMethodBody(ios, "void Relaunch(IDictionary options, Action started)"); + Assert.That(relaunch, Does.Contain("LaunchOrDefer(\"Relaunch\", () => RelaunchNow(options, started), started)")); + string defer = ExtractMethodBody(ios, "void LaunchOrDefer(string name, Action launchNow, Action started)"); + Assert.That(defer, Does.Contain("StopWaiters.TryDeferLaunch(")); + Assert.That(defer, Does.Contain("cancelled?.Invoke(false)")); + + string stop = ExtractMethodBody(ios, "void Stop(Action completion, Action finished)"); + // The SDK keeps the channel live across stop and never hands out a second one to the + // same wrapper object, so clearing it on Stop would silence it for every later launch. + Assert.That(stop, Does.Not.Contain("wrapper_channel")); + Assert.That(stop, Does.Contain("StopWithSoftDeadline(completion, finished)")); + // A Stop whose body the main thread never ran faults StopAsync, drops the public completion (it cannot + // signal failure, and Stop(() => Launch(t)) would launch over a session never stopped), and arms nothing. + int skipped = stop.IndexOf("Debug.LogError(\"[Bugsee] Stop timed out", StringComparison.Ordinal); + Assert.That(skipped, Is.GreaterThan(0)); + string afterSkip = stop.Substring(skipped); + Assert.That(afterSkip, Does.Contain("RunResult(finished, false)")); + Assert.That(afterSkip, Does.Not.Contain("RunCompletion(")); + Assert.That(afterSkip, Does.Not.Contain("completion()")); + Assert.That(afterSkip, Does.Contain("its completion was dropped")); + Assert.That(afterSkip, Does.Not.Contain("Arm")); + Assert.That(afterSkip, Does.Not.Contain("GiveUp")); + + // Soft deadline: per Stop call, runs only that call's completion (taken once, by token); the stop stays + // in flight, so Launch keeps waiting and a further Stop joins. + string soft = ExtractMethodBody(ios, "void StopWithSoftDeadline(Action completion, Action finished)"); + Assert.That(soft, Does.Contain("StopCompletions.Register(")); + Assert.That(soft, Does.Contain("if (StopNativeThen(run, dropped)) ArmStopSoftDeadline(token);")); + string softTimer = ExtractMethodBody(ios, "static void ArmStopSoftDeadline(int token)"); + Assert.That(softTimer, Does.Contain("StopCompletions.TryTake(token, out var callback)")); + Assert.That(softTimer, Does.Contain("callback(false)")); + Assert.That(softTimer, Does.Not.Contain("CompleteStop")); + Assert.That(softTimer, Does.Not.Contain("StopWaiters")); + Assert.That(ios, Does.Contain("const int StopSoftDeadlineMs = 10000;")); + + // One native stop at a time, shared by Stop and Delete. Only the hard deadline gives up on it. + string stopNative = ExtractMethodBody(ios, "bool StopNativeThen(Action then, Action dropped = null)"); + Assert.That(stopNative, Does.Contain("StopWaiters.TryJoin(then, generation, out var cancelledLaunch, dropped)")); + Assert.That(stopNative, Does.Contain("cancelledLaunch?.Invoke(false)")); + Assert.That(stopNative, Does.Contain("GiveUpIfStopNotReported(token)")); + Assert.That(stopNative, Does.Contain("_bugsee_stop(token, OnNativeStopped)")); + string watchdog = ExtractMethodBody(ios, "static void GiveUpIfStopNotReported(int token)"); + Assert.That(watchdog, Does.Contain("AfterOnMainThread(StopHardDeadlineMs")); + Assert.That(watchdog, Does.Contain("CompleteStop(token)")); + Assert.That(ios, Does.Contain("const int StopHardDeadlineMs = 60000;")); + Assert.That(ExtractMethodBody(ios, "static void AfterOnMainThread(int delayMs, Action action)"), + Does.Contain("MainThreadDispatcher.Run(")); + + // The launch answer: matched by token, taken once, with a status fallback at the deadline. + string started = ExtractMethodBody(ios, "static void OnNativeStarted(int token, int success)"); + Assert.That(started, Does.Contain("LaunchResults.TryTake(token, out var callback)")); + Assert.That(started, Does.Contain("catch (Exception")); + Assert.That(ios, Does.Contain("[MonoPInvokeCallback(typeof(LaunchStartedNativeCallback))]")); + string register = ExtractMethodBody(ios, "int RegisterLaunchResult(Action started)"); + Assert.That(register, Does.Contain("if (started == null) return 0;")); + Assert.That(register, Does.Contain("LaunchResults.TryTake(token, out var callback)")); + + // Status comes from the SDK, so Launching/Stopping show and a refused launch reads as Stopped. + string getStatus = ExtractMethodBody(ios, "public BugseeStatus GetStatus()"); + Assert.That(getStatus, Does.Contain("_bugsee_get_status()")); + Assert.That(ios, Does.Not.Contain("_launched"), "the managed flag was dead once status came from the SDK"); + + // Delete tears the managed hooks down unconditionally, in one main-thread closure: Launch installs them + // even when the SDK then refuses it, and the SDK's status must not be read off the main thread. + string delete = ExtractMethodBody(ios, "public void DeleteCollectedDataOnDevice()"); + Assert.That(delete, Does.Contain("RunSyncLifecycle")); + // ...but only for the generation it was requested in: the fence comes before any teardown or stop. + int fence = delete.IndexOf("DeleteCollectedDataLaunchGeneration.RunIfCurrent(deleteGeneration,", StringComparison.Ordinal); + Assert.That(fence, Is.GreaterThan(delete.IndexOf("RunSyncLifecycle", StringComparison.Ordinal))); + Assert.That(fence, Is.LessThan(delete.IndexOf("HostLogForwarder.Uninstall()", StringComparison.Ordinal))); + Assert.That(fence, Is.LessThan(delete.IndexOf("StopNativeThen(", StringComparison.Ordinal))); + Assert.That(delete, Does.Contain("HostLogForwarder.Uninstall()")); + Assert.That(delete, Does.Contain("ExceptionPipeline.Uninstall()")); + Assert.That(delete, Does.Not.Contain("GetStatus()")); + Assert.That(delete, Does.Contain("StopNativeThen(")); + + // Stop: matched by token, exception-safe, with a watchdog. + string native = ExtractMethodBody(ios, "static void OnNativeStopped(int token)"); + Assert.That(native, Does.Contain("catch (Exception")); + Assert.That(native, Does.Contain("CompleteStop(token)")); + string completeStop = ExtractMethodBody(ios, "static void CompleteStop(int token)"); + Assert.That(completeStop, Does.Contain("DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete, RunCompletion")); + string completion = ExtractMethodBody(ios, "static void RunCompletion(Action completion)"); + Assert.That(completion, Does.Contain("catch (Exception")); + Assert.That(ios, Does.Contain("[MonoPInvokeCallback(typeof(StopNativeCallback))]")); string facadeLifecycle = ExtractMethodBody( File.ReadAllText(RepoFile("Runtime/Bugsee.cs")), diff --git a/Tests~/WrapperPolicy/StopWaitListTests.cs b/Tests~/WrapperPolicy/StopWaitListTests.cs new file mode 100644 index 0000000..07afa2e --- /dev/null +++ b/Tests~/WrapperPolicy/StopWaitListTests.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class StopWaitListTests + { + static void Run(Action waiter) => waiter(); + + static bool TryJoin(StopWaitList list, Action waiter, int generation, Action dropped = null) + { + var joined = list.TryJoin(waiter, generation, out var cancelled, dropped); + cancelled?.Invoke(false); + return joined; + } + + static bool TryDeferLaunch(StopWaitList list, Action launch) + { + var deferred = list.TryDeferLaunch(launch, out var cancelled); + cancelled?.Invoke(false); + return deferred; + } + + [Test] + public void Nothing_to_join_before_a_stop_is_in_flight() + { + var list = new StopWaitList(); + Assert.That(TryJoin(list, () => { }, 0), Is.False); + } + + [Test] + public void A_second_stop_joins_the_one_in_flight_and_both_run_on_its_report() + { + var list = new StopWaitList(); + var ran = new List(); + var token = list.Begin(() => ran.Add("first"), 1); + + Assert.That(TryJoin(list, () => ran.Add("second"), 1), Is.True); + Assert.That(TryJoin(list, null, 1), Is.True, "a Stop without a completion still must not issue another native stop"); + + Assert.That(list.Complete(token, () => 1, Run), Is.False); + Assert.That(ran, Is.EqualTo(new[] { "first", "second" })); + } + + [Test] + public void After_the_report_the_next_stop_issues_its_own() + { + var list = new StopWaitList(); + var token = list.Begin(null, 1); + list.Complete(token, () => 1, Run); + Assert.That(TryJoin(list, () => { }, 1), Is.False); + } + + [Test] + public void A_stop_issued_before_a_launch_is_not_joined_after_it() + { + var list = new StopWaitList(); + list.Begin(null, 1); + Assert.That(TryJoin(list, () => { }, 2), Is.False); + } + + [Test] + public void Waiters_are_dropped_when_a_launch_happened_before_the_report() + { + var list = new StopWaitList(); + var ran = false; + var token = list.Begin(() => ran = true, 1); + + Assert.That(list.Complete(token, () => 2, Run), Is.True); + Assert.That(ran, Is.False); + } + + [Test] + public void A_launch_made_by_an_earlier_waiter_drops_the_later_ones() + { + // Stop(() => Launch(token)); DeleteCollectedDataOnDevice(); -- the joined delete must not stop the + // session the first completion just launched. + var list = new StopWaitList(); + var generation = 1; + var ran = new List(); + var token = list.Begin(() => { ran.Add("launch"); generation++; }, 1); + Assert.That(TryJoin(list, () => ran.Add("delete"), 1), Is.True); + + Assert.That(list.Complete(token, () => generation, Run), Is.True); + Assert.That(ran, Is.EqualTo(new[] { "launch" })); + } + + [Test] + public void A_dropped_waiter_runs_its_dropped_action_so_nothing_waits_forever() + { + var list = new StopWaitList(); + var generation = 1; + var ran = new List(); + var token = list.Begin(() => generation++, 1); + TryJoin(list, () => ran.Add("ran"), 1, () => ran.Add("dropped")); + + list.Complete(token, () => generation, Run); + Assert.That(ran, Is.EqualTo(new[] { "dropped" })); + } + + [Test] + public void A_waiter_can_issue_the_next_stop() + { + var list = new StopWaitList(); + var next = 0; + var token = list.Begin(() => next = list.Begin(null, 1), 1); + list.Complete(token, () => 1, Run); + Assert.That(list.IsPending(next), Is.True); + Assert.That(TryJoin(list, () => { }, 1), Is.True); + } + + [Test] + public void A_report_runs_its_waiters_once() + { + var list = new StopWaitList(); + var count = 0; + var token = list.Begin(() => count++, 1); + list.Complete(token, () => 1, Run); + list.Complete(token, () => 1, Run); + Assert.That(count, Is.EqualTo(1)); + Assert.That(list.IsPending(token), Is.False); + } + + [Test] + public void Waiters_stay_queued_until_reported() + { + var list = new StopWaitList(); + var token = list.Begin(() => { }, 1); + Assert.That(list.IsPending(token), Is.True); + Assert.That(TryJoin(list, () => { }, 1), Is.True); + } + + [Test] + public void A_late_report_of_an_older_stop_does_not_release_the_newer_one() + { + var list = new StopWaitList(); + var older = list.Begin(null, 1); + var newer = list.Begin(null, 2); + list.Complete(older, () => 2, Run); + Assert.That(TryJoin(list, () => { }, 2), Is.True); + Assert.That(list.IsPending(newer), Is.True); + } + + // The soft deadline runs a Stop's own completion (held outside the list, see OneShotCallbacks) and leaves + // the stop in flight: a Launch that completion makes, or any later one, still waits, and a further Stop + // joins instead of issuing a second native stop. + [Test] + public void Before_the_hard_deadline_the_stop_stays_in_flight_for_launches_and_stops() + { + var list = new StopWaitList(); + var ran = new List(); + var token = list.Begin(null, 1); + + // What a completion run at the soft deadline does: Stop(() => Launch(t)). + Assert.That(TryDeferLaunch(list, run => ran.Add("launch " + run)), Is.True); + Assert.That(ran, Is.Empty, "no launch while the native stop may still be running"); + Assert.That(TryDeferLaunch(list, run => ran.Add("later launch " + run)), Is.True); + Assert.That(TryJoin(list, () => ran.Add("nested stop"), 1), Is.True, "no second native stop"); + Assert.That(list.IsPending(token), Is.True); + + list.Complete(token, () => 1, Run); // the report, or the hard deadline + Assert.That(ran, Is.EqualTo(new[] { "launch False", "later launch False", "nested stop" })); + } + + // Giving up on a stop the SDK never reports (the hard deadline) is the same Complete call, made by the + // bridge's timer. + [Test] + public void Giving_up_on_a_stop_runs_its_waiters_frees_later_calls_and_ignores_the_late_report() + { + var list = new StopWaitList(); + var count = 0; + var token = list.Begin(() => count++, 1); + TryJoin(list, () => count++, 1); + + list.Complete(token, () => 1, Run); // the deadline + Assert.That(count, Is.EqualTo(2), "awaiting code must not hang"); + Assert.That(TryJoin(list, () => { }, 1), Is.False, "a later Stop issues its own native stop"); + Assert.That(TryDeferLaunch(list, _ => { }), Is.False, "a later Launch runs now"); + + list.Complete(token, () => 1, Run); // the SDK's late report + Assert.That(count, Is.EqualTo(2)); + } + + [Test] + public void A_launch_runs_now_when_no_stop_is_in_flight() + { + var list = new StopWaitList(); + Assert.That(TryDeferLaunch(list, _ => Assert.Fail("not deferred")), Is.False); + } + + [Test] + public void A_launch_during_a_stop_runs_after_it_and_after_its_waiters() + { + var list = new StopWaitList(); + var ran = new List(); + var token = list.Begin(() => ran.Add("stop"), 1); + Assert.That(TryDeferLaunch(list, run => ran.Add("launch " + run)), Is.True); + Assert.That(ran, Is.Empty); + + list.Complete(token, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "stop", "launch True" })); + } + + [Test] + public void Of_two_launches_during_a_stop_the_latest_wins() + { + var list = new StopWaitList(); + var ran = new List(); + var token = list.Begin(null, 1); + TryDeferLaunch(list, run => ran.Add("A " + run)); + Assert.That(ran, Is.Empty); + TryDeferLaunch(list, run => ran.Add("B " + run)); + Assert.That(ran, Is.EqualTo(new[] { "A False" }), "the earlier launch is cancelled at once, not at the report"); + + list.Complete(token, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "A False", "B True" })); + } + + [Test] + public void A_stop_after_a_deferred_launch_cancels_it() + { + var list = new StopWaitList(); + var ran = new List(); + var token = list.Begin(null, 1); + TryDeferLaunch(list, run => ran.Add("launch " + run)); + Assert.That(TryJoin(list, () => ran.Add("stop"), 1), Is.True); + Assert.That(ran, Is.EqualTo(new[] { "launch False" })); + + list.Complete(token, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "launch False", "stop" })); + } + + [Test] + public void A_stop_before_a_deferred_launch_does_not_cancel_it() + { + var list = new StopWaitList(); + var ran = new List(); + var token = list.Begin(null, 1); + TryJoin(list, () => ran.Add("delete"), 1); + TryDeferLaunch(list, run => ran.Add("launch " + run)); + + list.Complete(token, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "delete", "launch True" })); + } + + [Test] + public void A_waiter_that_launches_or_stops_again_cancels_the_deferred_launch() + { + // Stop(() => Launch(a)); Launch(b); -- Launch(a) is called last, when the stop finishes, so it wins. + var list = new StopWaitList(); + var generation = 1; + var ran = new List(); + var token = list.Begin(() => + { + Assert.That(TryDeferLaunch(list, _ => Assert.Fail("nothing in flight")), Is.False); + generation++; + ran.Add("a"); + }, 1); + TryDeferLaunch(list, run => ran.Add("b " + run)); + + list.Complete(token, () => generation, Run); + Assert.That(ran, Is.EqualTo(new[] { "a", "b False" })); + + // Stop(() => Stop()); Launch(b); -- the second Stop is newer than the deferred launch. + ran.Clear(); + var second = 0; + token = list.Begin(() => second = list.Begin(null, generation), generation); + TryDeferLaunch(list, run => ran.Add("b " + run)); + list.Complete(token, () => generation, Run); + Assert.That(ran, Is.EqualTo(new[] { "b False" })); + Assert.That(list.IsPending(second), Is.True); + } + } +} diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index a0833ef..1ae9cfc 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -1,6 +1,7 @@ #include #include #include +#import extern "C" { void _bugsee_set_wrapper_context(const char *json); @@ -12,6 +13,37 @@ void _bugsee_set_attribute(const char *key, const char *valueJson); char *_bugsee_get_attribute(const char *key); void _bugsee_clear_attribute(const char *key); +typedef void (*BugseeLaunchStartedFn)(int token, int success); +void _bugsee_launch(const char *appToken, const char *optionsJson, int startedToken, BugseeLaunchStartedFn started); +void _bugsee_relaunch(const char *optionsJson, int startedToken, BugseeLaunchStartedFn started); +typedef void (*BugseeStopCompletionFn)(int token); +void _bugsee_stop(int token, BugseeStopCompletionFn completion); +} + +static int gStartedToken; +static int gStartedCount; +static int gStoppedToken; + +static void OnStarted(int token, int success) +{ + (void)success; + gStartedToken = token; + gStartedCount++; +} + +static void OnStopped(int token) +{ + gStoppedToken = token; +} + +// Spins the main run loop until the condition holds or the time is up. +static int waitFor(int (^condition)(void), double seconds) +{ + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:seconds]; + while (!condition() && [deadline timeIntervalSinceNow] > 0) { + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + } + return condition(); } static void expect(int condition, const char *message) @@ -79,6 +111,17 @@ int main(void) free(attr); _bugsee_clear_attribute(c.name); } + // Lifecycle against the real SDK: answers come back with the caller's token. + _bugsee_launch("00000000-0000-0000-0000-000000000000", "{\"max_recording_time\":60}", 41, OnStarted); + expect(waitFor(^{ return gStartedCount == 1 ? 1 : 0; }, 5) && gStartedToken == 41, + "launch reports the SDK's started answer with its token"); + _bugsee_stop(7, OnStopped); + expect(waitFor(^{ return gStoppedToken == 7 ? 1 : 0; }, 5), "stop reports completion with its token"); + _bugsee_relaunch(NULL, 42, OnStarted); + expect(waitFor(^{ return gStartedCount == 2 ? 1 : 0; }, 5) && gStartedToken == 42, + "relaunch reports the SDK's started answer with its token"); + _bugsee_launch("00000000-0000-0000-0000-000000000000", NULL, 0, NULL); + expect(gStartedCount == 2, "a launch without a started callback calls nothing back"); printf("ios bridge tests passed\n"); return 0; From f0596fb0d0c01fcfd3a0e4a47d38983d41dd753c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:25:07 +0500 Subject: [PATCH 089/157] iOS: send wrapper_info at launch, type channel network events correctly, emit valid JSON. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on fix/ios-stop-lifecycle: this branch is stacked on it (both rewrite the Launch/Relaunch call lines in IOSBridge.cs, and that PR removes the dead _launched flag beside them), so it must merge after it. Three problems in what the iOS bridge sends to the SDK. wrapper_info: the SDK still reads {type, version, build, metal, fps} from the legacy wrapper_info launch option (BGSWrapperOptions.optionsFromDictionary, BugseeOptions.m). Nothing sent it. Launch and Relaunch now add it unless the caller supplied a usable one (a map whose "type" is a non-empty string; anything else is replaced with a warning, because the SDK indexes into it and sends isEqualToString: to "type", so a string in place of the map or a non-string type is an unrecognised-selector crash at launch). This is more than a hint: type "UNITY" switches the SDK's video capture to its Unity path (it hooks the Metal drawable and replaces the display-link pacing), and fps is used as a frame-skip divisor, so it has to match the real render rate. metal comes from the graphics API; fps from Application.targetFrameRate (30 when unset, Unity's mobile default), capped at the screen's refresh rate, and read at each Launch/Relaunch (the SDK reads it when recording starts), so a game that raises its frame rate later still captures at the old pace. NOT verified on a device: validate Unity video capture on iOS hardware before relying on this. The report's wrapper identity itself still comes from +setWrapper:. Related, found by running the sample on the iOS Simulator and NOT caused by this change: the app dies on its first frame with NSUnknownKeyException "[MTLSimTexture valueForUndefinedKey:] ... key iosurface", thrown inside the SDK's CaptureLayersUnity (called from BGSCaptureVideoSource displayLinkHandler). The SDK picks its Unity capture path with [UnityCapture isAvailable] (BGSCaptureVideoSource.m, in the display-link handler), a runtime check any Unity app passes - it does not depend on wrapper_info - and that path reads a private "iosurface" key from the drawable with key-value coding, which the simulator's texture does not have. It is an SDK bug (the call is unguarded) to fix there. Until then, set CaptureVideo = false when running a Unity app on the iOS Simulator; the sample reads BUGSEE_E2E_VIDEO=0 for that. (An earlier revision of this PR tried to avoid it by not sending wrapper_info on the simulator; that was based on a wrong theory, did not help, and is removed.) Channel network events: the stage was the C# enum name ("RequestStarted"), but the SDK types events by "before" (BugseeNetworkEventBegin - the string is "before", not "begin"), "complete", "cancel" and "error", checked against BugseeConstants.m and the beta6 binary. Events are now mapped to those strings. The timestamp was never set; it is now the event's own time when it was made by the bridge's factory (events held in the launch buffer keep the time they happened), else the time of submit, in Unix milliseconds - the SDK's unit (BugseeGetCurrentTimeStampMs). Any other INetworkEvent implementation has no timestamp and gets the submit time. Stages iOS cannot represent (redirect, timings-only, WebSocket without a sub-type) are dropped with a warning (one per event, which Unity's log forwarding then also records) instead of being mistyped. ParseStage also recognises the SDK's "before". The network filter's return path gets the same mapping when the filter returns a replacement event (e.g. one made by the exchange factory, which stores the managed stage name); an event the filter mutated in place keeps the SDK's own type string, and a replacement with no iOS stage is dropped. JSON: the hand-rolled encoder escaped only backslash, quote, CR and LF, so a tab or any other control character produced JSON NSJSONSerialization rejects and launch options silently fell back to defaults (labels became nil). It also emitted NaN/Infinity, and wrote a nested dictionary as its type name without quotes. It is now JsonWire in the policy assembly (plain C#, unit-tested with a real JSON parser): control characters and unpaired surrogates are escaped, NaN/Infinity are null, enums go by value, DateTime is ISO 8601, byte[] is base64, keys are culture-invariant, other types are quoted text, a self-referencing collection stops at depth 32, and a null value is omitted from a map. Behaviour change: attribute and trace values now use the same encoder, so an enum is sent by value (it was its name), a collection as JSON (it was its type name), and NaN as null (it was the string "NaN"). Tests: JsonWireTests (valid JSON for every case, de-DE culture, control characters, surrogates, nesting, depth limit; mutation-checked: dropping the control-character escape, the NaN rule or the depth limit makes them fail), IosHostPolicyTests (frame rate cap, wrapper_info validity including non-string and empty types), and source tests pinning the stage strings (submit and filter return), the wrapper_info wiring and the millisecond timestamp. The option-gate test's expected call shape follows the wrapper_info wrap. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Plugins/iOS/BugseeUnityCallbacks.mm | 6 + Runtime/Platform/IOS/IOSBridge.cs | 97 +++++----- Runtime/Platform/IOS/IosExchangeEvents.cs | 42 ++++- Runtime/Platform/IOS/IosNativeCallbacks.cs | 18 +- Runtime/WrapperPolicy/IosHostPolicy.cs | 31 +++ Runtime/WrapperPolicy/IosHostPolicy.cs.meta | 11 ++ Runtime/WrapperPolicy/JsonWire.cs | 163 ++++++++++++++++ Runtime/WrapperPolicy/JsonWire.cs.meta | 11 ++ Tests~/WrapperPolicy/FilterCompletionTests.cs | 11 ++ Tests~/WrapperPolicy/IosHostPolicyTests.cs | 34 ++++ Tests~/WrapperPolicy/JsonWireTests.cs | 176 ++++++++++++++++++ .../WrapperPolicy/OptionPlatformGateTests.cs | 3 +- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 25 +++ 13 files changed, 572 insertions(+), 56 deletions(-) create mode 100644 Runtime/WrapperPolicy/IosHostPolicy.cs create mode 100644 Runtime/WrapperPolicy/IosHostPolicy.cs.meta create mode 100644 Runtime/WrapperPolicy/JsonWire.cs create mode 100644 Runtime/WrapperPolicy/JsonWire.cs.meta create mode 100644 Tests~/WrapperPolicy/IosHostPolicyTests.cs create mode 100644 Tests~/WrapperPolicy/JsonWireTests.cs diff --git a/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 1160f7f..7a14c27 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -152,6 +152,12 @@ static void BugseeUnityApplyNetworkDict(BugseeNetworkEvent *event, NSDictionary id stage = d[@"stage"]; if ([stage isKindOfClass:[NSString class]]) event.bugseeNetworkEventType = stage; + id timestampMs = d[@"timestampMs"]; + if ([timestampMs respondsToSelector:@selector(doubleValue)] && [timestampMs doubleValue] > 0) { + // Unix milliseconds, the SDK's own unit (BugseeGetCurrentTimeStampMs). + event.timestamp = [timestampMs doubleValue]; + } + id body = d[@"body"]; if ([body isKindOfClass:[NSString class]]) { event.body = [(NSString *)body dataUsingEncoding:NSUTF8StringEncoding]; diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index d7d1531..63898da 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -119,7 +119,7 @@ void LaunchNow(string appToken, IDictionary options, Action options, Action started) _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); var startedToken = RegisterLaunchResult(started); - _bugsee_relaunch(ToJsonObject(OptionPlatformGate.ForIos(options)), + _bugsee_relaunch(ToJsonObject(WithWrapperInfo(OptionPlatformGate.ForIos(options))), startedToken, startedToken == 0 ? null : (LaunchStartedNativeCallback)OnNativeStarted); ExceptionPipeline.Install(this, options); HostLogForwarder.InstallOnce(this); @@ -720,8 +720,16 @@ void SubmitNetworkEventToChannel(INetworkEvent networkEvent) CopyNetworkFields(networkEvent, iosEvent); } + var nativeStage = IosNetworkEvent.ToNativeStage(iosEvent.Stage); + if (nativeStage == null) + { + Debug.LogWarning("[Bugsee] Dropped network event with stage " + iosEvent.Stage + ": no iOS equivalent."); + return; + } + var requiresFiltering = ChannelSubmit.NetworkRequiresFiltering() ? 1 : 0; - var json = IosNativeCallbacks.ToNativeMapJson(iosEvent.ToResultJson(), "headers"); + var json = IosNativeCallbacks.ToNativeMapJson( + iosEvent.ToChannelJson(nativeStage, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), "headers"); _bugsee_channel_network(json, requiresFiltering); } @@ -752,67 +760,49 @@ static string ConsumeNativeString(IntPtr ptr) } } - static string ToJsonStringArray(IList labels) - { - if (labels == null || labels.Count == 0) return null; - var parts = new List(labels.Count); - foreach (var label in labels) - { - if (label == null) continue; - parts.Add(JsonString(label)); - } - return parts.Count == 0 ? null : "[" + string.Join(",", parts.ToArray()) + "]"; - } + // The JSON encoder is plain C# in the policy assembly (JsonWire), where it is unit-tested. + static string ToJsonStringArray(IList labels) => JsonWire.StringArray(labels); - static string ToJsonObject(IDictionary map) + /// + /// The SDK still reads host hints (Metal capture path, frame rate) from this launch option; the wrapper + /// protocol carries only type, version, build and context. + /// + static IDictionary WithWrapperInfo(IDictionary options) { - if (map == null || map.Count == 0) return null; - var parts = new List(); - foreach (var kv in map) + var merged = options != null + ? new Dictionary(options) + : new Dictionary(); + merged.TryGetValue("wrapper_info", out var supplied); + if (IosHostPolicy.IsUsableWrapperInfo(supplied)) { - if (kv.Key == null || kv.Value == null) continue; - parts.Add(JsonString(kv.Key) + ":" + ToJsonLiteral(kv.Value)); + return merged; // the caller's own, with a string type } - return "{" + string.Join(",", parts.ToArray()) + "}"; - } - static string ToJsonValue(object value) - { - if (value == null) return "null"; - if (value is string || value is bool || value is byte || value is sbyte - || value is short || value is ushort || value is int || value is uint - || value is long || value is ulong || value is float || value is double - || value is decimal) + if (supplied != null) { - return ToJsonLiteral(value); + // The SDK indexes into it as a map and compares "type" as a string: anything else is a crash at launch. + Debug.LogWarning("[Bugsee] The wrapper_info option must be a map with a string \"type\"; it was replaced."); + merged.Remove("wrapper_info"); } - return JsonString(value.ToString()); - } - static string ToJsonLiteral(object value) - { - switch (value) + merged["wrapper_info"] = new Dictionary { - case bool b: - return b ? "true" : "false"; - case string s: - return JsonString(s); - case float f: - return f.ToString(System.Globalization.CultureInfo.InvariantCulture); - case double d: - return d.ToString(System.Globalization.CultureInfo.InvariantCulture); - case decimal m: - return m.ToString(System.Globalization.CultureInfo.InvariantCulture); - default: - return Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture); - } + { "type", "UNITY" }, + { "version", BugseePackageVersion.Version }, + { "build", BugseePackageVersion.Build }, + { "metal", SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Metal }, + { "fps", IosHostPolicy.CaptureFrameRate(Application.targetFrameRate, Screen.currentResolution.refreshRate) }, + }; + return merged; } - static string JsonString(string s) - { - if (s == null) return "null"; - return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r") + "\""; - } + static string ToJsonObject(IDictionary map) => JsonWire.Object(map); + + static string ToJsonValue(object value) => JsonWire.Value(value); + + static string ToJsonLiteral(object value) => JsonWire.Literal(value); + + static string JsonString(string s) => JsonWire.String(s); [DllImport("__Internal")] static extern void _bugsee_launch( string appToken, string optionsJson, int startedToken, LaunchStartedNativeCallback started); @@ -1053,6 +1043,7 @@ public INetworkEvent CreateNetworkEvent( method = method, mechanism = mechanism, stage = stage.ToString(), + timestampMs = timestamp, }); } } diff --git a/Runtime/Platform/IOS/IosExchangeEvents.cs b/Runtime/Platform/IOS/IosExchangeEvents.cs index d834a22..d621e2b 100644 --- a/Runtime/Platform/IOS/IosExchangeEvents.cs +++ b/Runtime/Platform/IOS/IosExchangeEvents.cs @@ -28,6 +28,7 @@ sealed class IosNetworkDto public string errorShortMessage; public string errorDescription; public string stage; + public long timestampMs; public IosKv[] headers; } @@ -125,11 +126,50 @@ public string ToResultJson() return JsonUtility.ToJson(_dto); } + /// + /// Serializes for the wrapper channel. The SDK types events by its own strings + /// ("before" / "complete" / "cancel" / "error": BugseeNetworkEventBegin is the string "before"), + /// not by names. The timestamp is the event's own time when it has + /// one, else (Unix milliseconds, the unit the SDK uses). + /// + internal string ToChannelJson(string nativeStage, long timestampMs) + { + _dto.headers = ToKv(_headers); + var stage = _dto.stage; + var stamp = _dto.timestampMs; + _dto.stage = nativeStage; + // An event minted earlier (and held in the launch buffer) keeps the time it happened. + if (_dto.timestampMs <= 0 && timestampMs > 0) _dto.timestampMs = timestampMs; + try { return JsonUtility.ToJson(_dto); } + finally + { + _dto.stage = stage; + _dto.timestampMs = stamp; + } + } + + /// + /// SDK event type for a stage, or null when iOS has no equivalent (redirect, timings-only, + /// WebSocket without a sub-type): such events are not submitted rather than mistyped. + /// + internal static string ToNativeStage(NetworkEventStage stage) + { + switch (stage) + { + case NetworkEventStage.RequestStarted: return "before"; + case NetworkEventStage.RequestCompleted: return "complete"; + case NetworkEventStage.RequestErrored: return "error"; + case NetworkEventStage.RequestAborted: return "cancel"; + default: return null; + } + } + static NetworkEventStage ParseStage(string stage) { if (string.IsNullOrEmpty(stage)) return NetworkEventStage.RequestCompleted; if (stage.IndexOf("Begin", StringComparison.OrdinalIgnoreCase) >= 0 - || stage.IndexOf("Start", StringComparison.OrdinalIgnoreCase) >= 0) + || stage.IndexOf("Start", StringComparison.OrdinalIgnoreCase) >= 0 + || string.Equals(stage, "before", StringComparison.OrdinalIgnoreCase)) // the SDK's BugseeNetworkEventBegin return NetworkEventStage.RequestStarted; if (stage.IndexOf("Cancel", StringComparison.OrdinalIgnoreCase) >= 0 || stage.IndexOf("Abort", StringComparison.OrdinalIgnoreCase) >= 0) diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 29fa701..f1622cb 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -173,7 +173,23 @@ static void CompleteNetwork(long requestId, string json, EventFilter filter) diff --git a/Runtime/WrapperPolicy/IosHostPolicy.cs b/Runtime/WrapperPolicy/IosHostPolicy.cs new file mode 100644 index 0000000..42f2d14 --- /dev/null +++ b/Runtime/WrapperPolicy/IosHostPolicy.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// Decisions about the iOS host that the launch options depend on. + public static class IosHostPolicy + { + /// + /// The frame rate to tell the SDK: the target when it is set, 30 (Unity's mobile default) when it is not, + /// and never more than the screen can show, since the SDK uses it as a frame-skip divisor. + /// + public static int CaptureFrameRate(int targetFrameRate, int screenRefreshRate) + { + if (targetFrameRate <= 0) return 30; + return screenRefreshRate > 0 ? Math.Min(targetFrameRate, screenRefreshRate) : targetFrameRate; + } + + /// + /// Whether a caller-supplied wrapper_info can be passed through: the SDK reads it as a map and indexes + /// into it (a string there is an unrecognised-selector crash at launch), it compares "type" with + /// isEqualToString: (anything but a string there crashes the same way), and a map without a "type" silently + /// turns the Unity capture path off. + /// + public static bool IsUsableWrapperInfo(object supplied) => + supplied is IDictionary map + && map.TryGetValue("type", out var type) + && type is string name + && name.Length > 0; + } +} diff --git a/Runtime/WrapperPolicy/IosHostPolicy.cs.meta b/Runtime/WrapperPolicy/IosHostPolicy.cs.meta new file mode 100644 index 0000000..0d08500 --- /dev/null +++ b/Runtime/WrapperPolicy/IosHostPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd5a4ebe84b2438d94cc9106656a67ad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/JsonWire.cs b/Runtime/WrapperPolicy/JsonWire.cs new file mode 100644 index 0000000..9f96a8e --- /dev/null +++ b/Runtime/WrapperPolicy/JsonWire.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// + /// The JSON the iOS bridge hands to Objective-C (launch options, event parameters, attribute values). + /// NSJSONSerialization rejects anything invalid and the callers then fall back silently - launch options become + /// "no options" - so every value must come out as valid JSON whatever the app put in it: + /// control characters and unpaired surrogates are escaped, NaN/Infinity become null, an enum crosses by value + /// (never name or ordinal), unknown types become text, keys are culture-invariant, and a self-referencing + /// collection stops at a depth limit instead of overflowing the stack. A null value is omitted from a + /// dictionary (an unset option falls back to the SDK's default) and written as null inside an array. + /// + public static class JsonWire + { + public const int MaxDepth = 32; + + /// A JSON object for the map, or null when it is null or empty. + public static string Object(IDictionary map) => + map == null || map.Count == 0 ? null : WriteMap(map, 0); + + /// A JSON array of strings, or null when there are none. + public static string StringArray(IList items) + { + if (items == null || items.Count == 0) return null; + var parts = new List(items.Count); + foreach (var item in items) + { + if (item != null) parts.Add(String(item)); + } + + return parts.Count == 0 ? null : "[" + string.Join(",", parts.ToArray()) + "]"; + } + + /// One value as a JSON literal; null is "null". + public static string Value(object value) => value == null ? "null" : Literal(value, 0); + + public static string Literal(object value) => Literal(value, 0); + + public static string String(string s) + { + if (s == null) return "null"; + var sb = new StringBuilder(s.Length + 2); + sb.Append('"'); + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + switch (c) + { + case '\\': sb.Append("\\\\"); break; + case '"': sb.Append("\\\""); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + default: + if (c < 0x20) + { + sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else if (char.IsHighSurrogate(c) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + sb.Append(c).Append(s[++i]); // a valid pair is passed through + } + else if (char.IsSurrogate(c)) + { + // Unpaired: UTF-8 conversion at the native boundary would corrupt it and the whole + // payload could be dropped, so write it as an escape. + sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } + else + { + sb.Append(c); + } + + break; + } + } + + return sb.Append('"').ToString(); + } + + static string WriteMap(IEnumerable entries, int depth) + { + var parts = new List(); + foreach (var entry in entries) + { + object key; + object value; + if (entry is DictionaryEntry de) { key = de.Key; value = de.Value; } + else + { + // KeyValuePair, the generic dictionary's element type + var type = entry.GetType(); + key = type.GetProperty("Key")?.GetValue(entry, null); + value = type.GetProperty("Value")?.GetValue(entry, null); + } + + if (key == null || value == null) continue; + parts.Add(String(Convert.ToString(key, CultureInfo.InvariantCulture)) + ":" + Literal(value, depth + 1)); + } + + return "{" + string.Join(",", parts.ToArray()) + "}"; + } + + static string Literal(object value, int depth) + { + if (value == null) return "null"; + if (depth > MaxDepth) return "null"; // a collection that contains itself + switch (value) + { + case bool b: + return b ? "true" : "false"; + case string s: + return String(s); + case float f: + return float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("R", CultureInfo.InvariantCulture); + case double d: + return double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("R", CultureInfo.InvariantCulture); + case decimal m: + return m.ToString(CultureInfo.InvariantCulture); + case sbyte _: + case byte _: + case short _: + case ushort _: + case int _: + case uint _: + case long _: + case ulong _: + return Convert.ToString(value, CultureInfo.InvariantCulture); + case Enum e: + // An enum crosses by its value, never its name or ordinal (wrapper workbook 7.4). + return Convert.ToString( + Convert.ChangeType(e, Enum.GetUnderlyingType(e.GetType()), CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); + case DateTime dt: + return String(dt.ToString("o", CultureInfo.InvariantCulture)); + case DateTimeOffset dto: + return String(dto.ToString("o", CultureInfo.InvariantCulture)); + case byte[] bytes: + return String(Convert.ToBase64String(bytes)); + case IDictionary nested: + return WriteMap(nested, depth); + case IDictionary map: + return WriteMap(map, depth); + case IEnumerable list: + { + var parts = new List(); + foreach (var item in list) parts.Add(item == null ? "null" : Literal(item, depth + 1)); + return "[" + string.Join(",", parts.ToArray()) + "]"; + } + default: + // char, Guid, TimeSpan, ... : text. Written bare it made the whole payload invalid. + return String(Convert.ToString(value, CultureInfo.InvariantCulture)); + } + } + } +} diff --git a/Runtime/WrapperPolicy/JsonWire.cs.meta b/Runtime/WrapperPolicy/JsonWire.cs.meta new file mode 100644 index 0000000..dabb4bb --- /dev/null +++ b/Runtime/WrapperPolicy/JsonWire.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: caf1df2dc4a342b5b38c32691e0a11f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/FilterCompletionTests.cs b/Tests~/WrapperPolicy/FilterCompletionTests.cs index 171d853..d874f83 100644 --- a/Tests~/WrapperPolicy/FilterCompletionTests.cs +++ b/Tests~/WrapperPolicy/FilterCompletionTests.cs @@ -138,6 +138,17 @@ public void Ios_complete_methods_keep_when_filter_null() "static void CompleteBreadcrumb(long requestId, string json, EventFilter filter)"); } + [Test] + public void Ios_network_filter_replacement_is_typed_with_the_sdk_stage() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosNativeCallbacks.cs")); + string complete = ExtractMethodBody( + ios, "static void CompleteNetwork(long requestId, string json, EventFilter filter)"); + Assert.That(complete, Does.Contain("IosNetworkEvent.ToNativeStage(ios.Stage)")); + Assert.That(complete, Does.Contain("ios.ToChannelJson(nativeStage, 0)")); + Assert.That(complete, Does.Contain("ReferenceEquals(ios, managed)")); + } + [Test] public void Ios_native_per_kind_filter_flags() { diff --git a/Tests~/WrapperPolicy/IosHostPolicyTests.cs b/Tests~/WrapperPolicy/IosHostPolicyTests.cs new file mode 100644 index 0000000..e605404 --- /dev/null +++ b/Tests~/WrapperPolicy/IosHostPolicyTests.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IosHostPolicyTests + { + [TestCase(0, 60, 30)] + [TestCase(-1, 60, 30)] + [TestCase(60, 60, 60)] + [TestCase(120, 60, 60)] + [TestCase(120, 120, 120)] + [TestCase(30, 60, 30)] + [TestCase(60, 0, 60)] + public void The_capture_frame_rate_never_exceeds_what_the_screen_shows(int target, int refresh, int expected) + { + Assert.That(IosHostPolicy.CaptureFrameRate(target, refresh), Is.EqualTo(expected)); + } + + [Test] + public void Only_a_map_with_a_type_is_a_usable_wrapper_info() + { + Assert.That(IosHostPolicy.IsUsableWrapperInfo(new Dictionary { { "type", "UNITY" } }), Is.True); + Assert.That(IosHostPolicy.IsUsableWrapperInfo(new Dictionary { { "fps", 30 } }), Is.False); + Assert.That(IosHostPolicy.IsUsableWrapperInfo(new Dictionary { { "type", null } }), Is.False); + // The SDK sends isEqualToString: to "type", so anything but a non-empty string is replaced. + Assert.That(IosHostPolicy.IsUsableWrapperInfo(new Dictionary { { "type", 1 } }), Is.False); + Assert.That(IosHostPolicy.IsUsableWrapperInfo(new Dictionary { { "type", true } }), Is.False); + Assert.That(IosHostPolicy.IsUsableWrapperInfo(new Dictionary { { "type", "" } }), Is.False); + Assert.That(IosHostPolicy.IsUsableWrapperInfo("a string the SDK would index into"), Is.False); + Assert.That(IosHostPolicy.IsUsableWrapperInfo(null), Is.False); + } + } +} diff --git a/Tests~/WrapperPolicy/JsonWireTests.cs b/Tests~/WrapperPolicy/JsonWireTests.cs new file mode 100644 index 0000000..91f440c --- /dev/null +++ b/Tests~/WrapperPolicy/JsonWireTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class JsonWireTests + { + enum Color : int { Red = 7, Green = 9 } + enum Big : ulong { Max = ulong.MaxValue } + + // NSJSONSerialization rejects anything a real parser rejects, so every output is parsed by one. + static JsonElement Parse(string json) + { + Assert.That(json, Is.Not.Null); + try { return JsonDocument.Parse(json).RootElement.Clone(); } + catch (JsonException ex) { Assert.Fail("not valid JSON: " + json + " (" + ex.Message + ")"); throw; } + } + + [Test] + public void An_empty_or_null_map_has_no_object() + { + Assert.That(JsonWire.Object(null), Is.Null); + Assert.That(JsonWire.Object(new Dictionary()), Is.Null); + } + + [Test] + public void Control_characters_are_escaped() + { + var json = JsonWire.Object(new Dictionary { { "k", "tab\there\nnew\u0001\u001f\"q\"\\" } }); + Assert.That(Parse(json).GetProperty("k").GetString(), Is.EqualTo("tab\there\nnew\u0001\u001f\"q\"\\")); + } + + [Test] + public void A_valid_surrogate_pair_is_passed_through_and_an_unpaired_one_is_escaped() + { + Assert.That(Parse(JsonWire.Value("\U0001F600")).GetString(), Is.EqualTo("\U0001F600")); + var lone = JsonWire.Value("a\uD800b"); + Assert.That(lone, Does.Contain("\\ud800")); + Parse(lone); + Parse(JsonWire.Value("a\uDC00")); + } + + [Test] + public void Non_finite_numbers_become_null() + { + foreach (var v in new object[] { float.NaN, float.PositiveInfinity, double.NaN, double.NegativeInfinity }) + Assert.That(JsonWire.Value(v), Is.EqualTo("null")); + } + + [Test] + public void Numbers_use_the_invariant_culture() + { + var old = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.That(JsonWire.Value(1.5), Is.EqualTo("1.5")); + Assert.That(JsonWire.Value(1.5f), Is.EqualTo("1.5")); + Assert.That(JsonWire.Value(2.25m), Is.EqualTo("2.25")); + Parse(JsonWire.Object(new Dictionary { { "d", 0.1 + 0.2 } })); + } + finally { CultureInfo.CurrentCulture = old; } + } + + [Test] + public void An_enum_crosses_by_value_not_name_or_ordinal() + { + Assert.That(JsonWire.Value(Color.Red), Is.EqualTo("7")); + Assert.That(JsonWire.Value(Color.Green), Is.EqualTo("9")); + Assert.That(JsonWire.Value(Big.Max), Is.EqualTo("18446744073709551615")); + } + + [Test] + public void Collections_and_dictionaries_nest() + { + var json = JsonWire.Object(new Dictionary + { + { "list", new List { 1, "two", null, 3.5 } }, + { "array", new[] { 1, 2 } }, + { "map", new Dictionary { { "a", 1 }, { "gone", null } } }, + { "strings", new Dictionary { { "x", "y" } } }, + { "nonGeneric", new System.Collections.Hashtable { { "k", "v" } } }, + }); + var root = Parse(json); + Assert.That(root.GetProperty("list").GetArrayLength(), Is.EqualTo(4)); + Assert.That(root.GetProperty("list")[2].ValueKind, Is.EqualTo(JsonValueKind.Null)); + Assert.That(root.GetProperty("map").TryGetProperty("gone", out _), Is.False, "a null value is omitted from a map"); + Assert.That(root.GetProperty("strings").GetProperty("x").GetString(), Is.EqualTo("y")); + Assert.That(root.GetProperty("nonGeneric").GetProperty("k").GetString(), Is.EqualTo("v")); + } + + [Test] + public void A_null_value_is_omitted_from_the_object_and_an_empty_nested_map_is_an_empty_object() + { + var json = JsonWire.Object(new Dictionary + { + { "keep", 1 }, { "drop", null }, { "empty", new Dictionary() }, + }); + var root = Parse(json); + Assert.That(root.TryGetProperty("drop", out _), Is.False); + Assert.That(root.GetProperty("empty").ValueKind, Is.EqualTo(JsonValueKind.Object)); + } + + [Test] + public void Other_types_become_text_instead_of_invalid_bare_tokens() + { + var guid = Guid.NewGuid(); + var json = JsonWire.Object(new Dictionary + { + { "char", 'x' }, { "guid", guid }, { "span", TimeSpan.FromSeconds(90) }, { "uri", new Uri("https://a.b/c?d=e") }, + }); + var root = Parse(json); + Assert.That(root.GetProperty("char").GetString(), Is.EqualTo("x")); + Assert.That(root.GetProperty("guid").GetString(), Is.EqualTo(guid.ToString())); + } + + [Test] + public void Dates_are_iso_8601_and_bytes_are_base64() + { + var when = new DateTime(2026, 10, 14, 1, 2, 3, DateTimeKind.Utc); + Assert.That(Parse(JsonWire.Value(when)).GetString(), Is.EqualTo("2026-10-14T01:02:03.0000000Z")); + Assert.That(Parse(JsonWire.Value(new byte[] { 1, 255 })).GetString(), Is.EqualTo("Af8=")); + } + + [Test] + public void Dictionary_keys_are_culture_invariant() + { + var old = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + var json = JsonWire.Value(new Dictionary { { 1.5, 1 } }); + Assert.That(Parse(json).GetProperty("1.5").GetInt32(), Is.EqualTo(1)); + } + finally { CultureInfo.CurrentCulture = old; } + } + + [Test] + public void A_collection_that_contains_itself_stops_at_the_depth_limit() + { + var loop = new List(); + loop.Add(loop); + var json = JsonWire.Value(loop); + Parse(json); + + var map = new Dictionary(); + map["self"] = map; + Parse(JsonWire.Object(map)); + } + + [Test] + public void String_arrays_skip_nulls_and_are_null_when_empty() + { + Assert.That(JsonWire.StringArray(null), Is.Null); + Assert.That(JsonWire.StringArray(new List()), Is.Null); + Assert.That(JsonWire.StringArray(new List { null }), Is.Null); + Assert.That(Parse(JsonWire.StringArray(new List { "a", null, "b\"" })).GetArrayLength(), Is.EqualTo(2)); + } + + [Test] + public void A_nested_wrapper_info_shaped_map_round_trips() + { + var json = JsonWire.Object(new Dictionary + { + { "wrapper_info", new Dictionary + { { "type", "UNITY" }, { "version", "0.1.0" }, { "build", "dev" }, { "metal", true }, { "fps", 30 } } }, + }); + var info = Parse(json).GetProperty("wrapper_info"); + Assert.That(info.GetProperty("metal").GetBoolean(), Is.True); + Assert.That(info.GetProperty("fps").GetInt32(), Is.EqualTo(30)); + } + } +} diff --git a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs index e0d7a30..b293107 100644 --- a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs +++ b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs @@ -9,7 +9,8 @@ namespace Bugsee.WrapperPolicy.Tests { public class OptionPlatformGateTests { - const string ForIosJsonCall = "ToJsonObject(OptionPlatformGate.ForIos(options))"; + // wrapper_info is added after the platform gate, so Android-only keys are still stripped first. + const string ForIosJsonCall = "ToJsonObject(WithWrapperInfo(OptionPlatformGate.ForIos(options)))"; static string FindRepoRoot() { diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 65866b1..5e4a07c 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -596,6 +596,31 @@ public void Ios_network_buffer_wiring() Assert.That(facadeLifecycle, Does.Contain("Bridge.NotifyLifecycle")); } + [Test] + public void Ios_launch_sends_wrapper_info_and_typed_channel_stages() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + + string launch = ExtractMethodBody(ios, "void LaunchNow(string appToken, IDictionary options, Action started)"); + Assert.That(launch, Does.Contain("WithWrapperInfo(")); + string relaunch = ExtractMethodBody(ios, "void RelaunchNow(IDictionary options, Action started)"); + Assert.That(relaunch, Does.Contain("WithWrapperInfo(")); + Assert.That(ios, Does.Contain("\"wrapper_info\"")); + Assert.That(ios, Does.Contain("IosNetworkEvent.ToNativeStage")); + + // The SDK's constants are begin="before", complete, cancel, error (BugseeConstants.m), and + // timestamps are Unix milliseconds: "begin" and a /1000 were both wrong on the first attempt. + string events = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosExchangeEvents.cs")); + Assert.That(events, Does.Contain("case NetworkEventStage.RequestStarted: return \"before\";")); + Assert.That(events, Does.Contain("case NetworkEventStage.RequestCompleted: return \"complete\";")); + Assert.That(events, Does.Contain("case NetworkEventStage.RequestAborted: return \"cancel\";")); + Assert.That(events, Does.Contain("case NetworkEventStage.RequestErrored: return \"error\";")); + Assert.That(events, Does.Not.Contain("return \"begin\"")); + string callbacks = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityCallbacks.mm")); + Assert.That(callbacks, Does.Contain("event.timestamp = [timestampMs doubleValue];")); + Assert.That(callbacks, Does.Not.Contain("/ 1000.0")); + } + [Test] public void Ios_breadcrumb_bridge_passes_ToIos_mapping() { From 2ad1020532bafbc767cf829e9c7835fbb80f759f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:27:32 +0500 Subject: [PATCH 090/157] Upload iOS symbols for every Xcode configuration, not only Release. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Bugsee Upload Symbols' Run Script skipped every configuration except Release unless BUGSEE_UPLOAD_ON_DEBUG=1. A build run from Xcode in the Debug configuration (the usual way to test on a device) crashed unsymbolicated, and ReleaseForRunning / ReleaseForProfiling were skipped as well. The phase runs on every build that has the token in its environment, not only archives, so simulator builds upload too. The script now uploads for every configuration. A build environment can skip Debug with BUGSEE_SKIP_UPLOAD_ON_DEBUG=1 (e.g. local Xcode runs that should not upload on each build). Unchanged: nothing uploads without BUGSEE_APP_TOKEN, and a failing upload never fails the build. When the build has neither the app dSYM nor UnityFramework.framework.dSYM (Unity exports Debug with DEBUG_INFORMATION_FORMAT=dwarf), the script now prints an Xcode warning naming the setting, instead of ending as if the symbols were uploaded. Checked by running the generated script with /bin/sh: app dSYM present, UnityFramework dSYM present (upload, no warning), neither (warning); all exit 0. Depends on fix/ios-dsym-every-configuration (Debug only has a dSYM with it) and fix/ios-symbol-upload-run-script (the Run Script otherwise fails to execute). Not verified: that the CLI skips a debug file whose UUID it already has, so a Debug build that is re-run unchanged may upload the same dSYM again. Docs (including the iOS checklist, which said "Archive") and CHANGELOG updated. Source tests fail against the old script (mutation-checked); the script itself was not run in Xcode here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + Documentation~/index.md | 8 ++-- Editor/BugseeSymbolUpload.cs | 11 ++++- .../WrapperPolicy/IosSymbolUploadGateTests.cs | 44 +++++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 45adfa7..b4a4064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Notes - Symbol upload is opt-in via `BUGSEE_APP_TOKEN` and never fails the Unity build. +- The iOS `Bugsee Upload Symbols` Run Script uploads for every Xcode configuration; `BUGSEE_SKIP_UPLOAD_ON_DEBUG=1` skips Debug (replaces `BUGSEE_UPLOAD_ON_DEBUG`). - iOS linemap UUID upload is performed at Xcode archive time when Unity export has no `UnityFramework` UUID yet. - Managed `frames[].address`: IL2CPP thrown exceptions get native IPs when available (index-aligned, no reverse); short IL offsets (`[0x00023]`) are ignored. - iOS terminating report handlers force-complete like Android (no async `done` wait). diff --git a/Documentation~/index.md b/Documentation~/index.md index d77d17b..898a74c 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -91,12 +91,14 @@ IL2CPP **C# file/line** via LNM always needs uploaded `il2cpp-linemap` + matchin Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`. +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. + | Artifact | When | CLI type | |---|---|---| -| `LineNumberMappings.json` (+ MethodMap) | Android post-build; iOS at **Xcode archive** | `il2cpp-linemap` | +| `LineNumberMappings.json` (+ MethodMap) | Android post-build; iOS at **Xcode build** (every configuration) | `il2cpp-linemap` | | Android `symbols.zip` / ELF | Android post-build | `elf` | | ProGuard / R8 `mapping.txt` | Android post-build when present | `proguard` | -| iOS `.dSYM` | Xcode archive Run Script (`Bugsee Upload Symbols`) | `dsym` | +| iOS `.dSYM` | Xcode Run Script (`Bugsee Upload Symbols`), every configuration | `dsym` | Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn and continue. @@ -113,7 +115,7 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn 1. Scripting Backend: IL2CPP 2. Enable source mapping / Script Debugging as needed for LineNumberMappings generation -3. Archive in Xcode (dSYM + UUID for `UnityFramework` are available at archive time) +3. Build or archive in Xcode: the Run Script uploads the dSYMs of the configuration being built. Debug has a dSYM only with `DEBUG_INFORMATION_FORMAT = dwarf-with-dsym` (Unity exports Debug as `dwarf`); without one the script prints a warning and uploads no dSYM 4. Ensure `BUGSEE_APP_TOKEN` is visible to the Xcode Run Script phase 5. The iOS export sets `DEBUG_INFORMATION_FORMAT=dwarf-with-dsym` on every configuration of Unity-iPhone and UnityFramework, so Debug builds have a dSYM too. `dsymutil` on UnityFramework adds time to each Debug build; to keep Unity's own setting, tick **Bugsee → iOS → Keep Xcode Debug Info Format (skip dSYM force)** or set `BUGSEE_NO_DSYM_FORCE` in the Editor's environment (CI) diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 4ef340c..d513a90 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -306,8 +306,10 @@ static string BuildArchiveScript() sb.AppendLine("set -e"); sb.AppendLine("# Bugsee archive-time symbol upload (dSYM + il2cpp-linemap)."); sb.AppendLine("# Never fail the Archive — swallow CLI errors."); - sb.AppendLine("if [ \"${CONFIGURATION}\" != \"Release\" ] && [ \"${BUGSEE_UPLOAD_ON_DEBUG}\" != \"1\" ]; then"); - sb.AppendLine(" echo \"Bugsee: skip symbol upload for ${CONFIGURATION} (set BUGSEE_UPLOAD_ON_DEBUG=1 to force).\""); + // Debug builds are the ones tested on devices (Unity's Development Build), and a crash from one is + // unsymbolicated without its dSYM, so every configuration uploads. Opt out per build environment. + sb.AppendLine("if [ \"${CONFIGURATION}\" = \"Debug\" ] && [ \"${BUGSEE_SKIP_UPLOAD_ON_DEBUG}\" = \"1\" ]; then"); + sb.AppendLine(" echo \"Bugsee: skip symbol upload for Debug (BUGSEE_SKIP_UPLOAD_ON_DEBUG=1).\""); sb.AppendLine(" exit 0"); sb.AppendLine("fi"); sb.AppendLine("if [ -z \"${BUGSEE_APP_TOKEN}\" ]; then"); @@ -338,6 +340,11 @@ static string BuildArchiveScript() sb.AppendLine(" upload \"${CLI}\" debug-files upload \"${UF_DSYM}\" --type dsym --version \"${VERSION}\" --build \"${BUILD}\""); sb.AppendLine(" fi"); sb.AppendLine("fi"); + // Unity exports Debug with DEBUG_INFORMATION_FORMAT=dwarf, which writes no dSYM: say so rather than + // finish as if the crash symbols were uploaded. + sb.AppendLine("if [ ! -d \"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\" ] && [ ! -d \"${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM\" ]; then"); + sb.AppendLine(" echo \"warning: Bugsee: no dSYM for ${CONFIGURATION} in ${DWARF_DSYM_FOLDER_PATH}; set DEBUG_INFORMATION_FORMAT=dwarf-with-dsym to symbolicate its crashes.\""); + sb.AppendLine("fi"); sb.AppendLine(""); sb.AppendLine("# --- il2cpp-linemap (UUID from UnityFramework binary / dSYM) ---"); sb.AppendLine("LINEMAP=\"\""); diff --git a/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs b/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs new file mode 100644 index 0000000..5fbe476 --- /dev/null +++ b/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class IosSymbolUploadGateTests + { + static string SymbolUploadSource() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return File.ReadAllText(Path.Combine(dir, "Editor", "BugseeSymbolUpload.cs")); + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + // A crash from a Debug (Development) build is unsymbolicated without its dSYM, so no configuration may be + // skipped unless the build environment asks. + [Test] + public void Archive_script_uploads_every_configuration_unless_debug_is_opted_out() + { + string text = SymbolUploadSource(); + Assert.That(text, Does.Not.Contain("!= \\\"Release\\\"")); + Assert.That(text, Does.Not.Contain("BUGSEE_UPLOAD_ON_DEBUG")); + Assert.That(text, Does.Contain("${BUGSEE_SKIP_UPLOAD_ON_DEBUG}")); + Assert.That(text, Does.Contain("${CONFIGURATION}\\\" = \\\"Debug\\\"")); + } + + // Unity's Debug configuration writes no dSYM, so an upload there would otherwise end silently with nothing sent. + [Test] + public void Archive_script_warns_when_the_build_has_no_dsym() + { + string text = SymbolUploadSource(); + Assert.That(text, Does.Contain( + "if [ ! -d \\\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\\\" ] && [ ! -d \\\"${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM\\\" ]; then")); + Assert.That(text, Does.Contain("echo \\\"warning: Bugsee: no dSYM for ${CONFIGURATION}")); + } + } +} From b57f66979a904f06902e1dfbef5d5f8156de7c7e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:41:32 +0500 Subject: [PATCH 091/157] Android: declare the Bugsee SDK in the generated Gradle project and drop EDM4U. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing the package is now just adding it in Package Manager: no OpenUPM scoped registry, no EDM4U, no dependency-resolve step, no custom Gradle templates to enable. Until now package.json depended on com.google.external-dependency-manager 1.2.188, which is not on Unity's registry (the README told users to add the OpenUPM scoped registry), Editor/BugseeAndroidDependencies.xml declared the Maven coordinates for it, and Editor/BugseeEdmJavaHomeBootstrap.cs worked around EDM4U's Gradle 5.1.1 resolver failing on JDK 17+. Unity 2021.3's generated Gradle project already lists google() and mavenCentral() in settings.gradle and unityLibrary/build.gradle has a plain top-level dependencies block, and bugsee-android publishes no Maven transitives (core + ndk only). So a new IPostGenerateGradleAndroidProject step, Editor/BugseeAndroidMavenDependencies.cs (callbackOrder 1000, after other post-generate steps), adds implementation 'com.bugsee:bugsee-android:' implementation 'com.bugsee:bugsee-android-ndk:' to the top-level dependencies block of unityLibrary/build.gradle, between `// bugsee:maven-dependencies begin/end` markers: - A re-run, a second build or an export into an existing folder does not duplicate them; the marked region is upserted when the pinned version changes. - If the project already declares bugsee-android or bugsee-android-ndk itself (EDM4U, a custom mainTemplate.gradle, a local AAR such as `name: 'bugsee-android-7.2.0'`), nothing is added and a region left by an earlier run is removed. The build log warns when that declaration's version differs from the package pin, or when only one of the two artifacts is declared. Commented-out lines and other artifacts (bugsee-android-feedback) do not count. - If there is no top-level dependencies block (or only a one-line one), a block of its own is appended. - mavenCentral() is added (marked `// bugsee:maven-repository`) to the dependencyResolutionManagement repositories in settings.gradle, or failing that to the root build.gradle allprojects repositories (older layout), only if it is missing there. mavenCentral() in pluginManagement does not count. If neither block exists, a warning is logged and nothing is edited. - If unityLibrary/build.gradle is missing (e.g. a Kotlin DSL project) or the edit throws, the build fails with a BuildFailedException: without the dependency the app builds but Bugsee cannot start. The step is not behind #if UNITY_ANDROID: IPostGenerateGradleAndroidProject is defined in UnityEditor.CoreModule (monodis on 2021.3.45; UnityEditor.Android.Extensions.dll does not define it), so it compiles without Android Build Support. Checked by compiling the injector with csc against only the editor's Managed/ assemblies, no PlaybackEngines reference: it compiles, while a type that lives only in the Android module (AndroidExternalToolsSettings) fails there with CS0234. A guard would drop the step silently whenever the active build target is not Android when scripts compile. The text editing is Runtime/WrapperPolicy/GradleMavenInjection.cs (pure, unit-tested); the Editor class only reads and writes files. Coordinates come from new generated constants BugseePackageVersion.AndroidMavenGroup / AndroidMavenArtifact / AndroidNdkArtifact next to AndroidSdkVersion, so Tools~/versions.env stays the single source; update-native-sdks.sh no longer generates the EDM4U XML. BugseeAndroidGradleSetup.SdkVersion now reads BugseePackageVersion.AndroidSdkVersion instead of repeating "7.4.1". Removed: the EDM4U dependency in package.json, Editor/BugseeAndroidDependencies.xml and Editor/BugseeEdmJavaHomeBootstrap.cs (+ .meta). Sample (Samples~/BugseeMiniGame): the OpenUPM scoped registry and EDM4U entries in Packages/manifest.json, packages-lock.json and PackageManagerSettings.asset; ProjectSettings/GvhProjectSettings.xml and AndroidResolverDependencies.xml; mainTemplate.gradle and settingsTemplate.gradle, which were Unity 2021.3's defaults plus EDM4U's sections only; the EDM4U section of gradleTemplate.properties (Unity itself writes android.useAndroidX=true). The sample keeps its baseProjectTemplate/launcherTemplate (written by BugseeAndroidGradleSetup) and android.suppressUnsupportedCompileSdk=35. README, Documentation~/index.md, DESIGN.md, CHANGELOG.md and the sample README describe the new install; projects that use EDM4U for other SDKs are unaffected. Verified with Unity 2021.3.45f1 (bundled SDK/NDK/JDK) on the sample with EDM4U absent (manifest.json, packages-lock.json, Library/PackageCache), IL2CPP ARM64 development builds from a throwaway batch method: - The APK builds; the generated unityLibrary/build.gradle has the marked region with both dependencies at the top of its dependencies block; settings.gradle already had mavenCentral(), so it was not edited. - The APK has lib/arm64-v8a/libbugsee-native.so, libbugsee-crashcp.so, libbugsee-crashpad-handler.so and libbugsee-crashpad-trampoline.so with the same sizes, and a classes.dex of the same size, as the earlier EDM4U build of the same sample. - A second APK build reused the generated build.gradle (Unity did not regenerate it): the step found its region and changed nothing, so the dependencies appear once. Two exports into the same folder: likewise. The build needed the AndroidReport.cs syntax fix from fix/compile-error-and-missing-metas, applied locally and not part of this commit (the base branch does not compile for Android without it). Tests: GradleMavenInjectionTests (Unity 2021.3 and Unity 6 shaped build.gradle, idempotence, version upsert, EDM4U / custom / local-AAR declarations kept with version and missing-NDK warnings, stale region removed, comments and feedback artifact ignored, nested buildscript dependencies skipped, missing and one-line blocks, CRLF, settings.gradle and allprojects repositories, mavenCentral by URL) and source tests pinning the injector to the generated constants, the constants to versions.env, and the package/sample to no EDM4U. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- DESIGN.md | 12 +- Documentation~/index.md | 12 +- Editor/BugseeAndroidDependencies.xml | 12 - Editor/BugseeAndroidDependencies.xml.meta | 7 - Editor/BugseeAndroidGradleSetup.cs | 8 +- Editor/BugseeAndroidMavenDependencies.cs | 128 ++++ ...=> BugseeAndroidMavenDependencies.cs.meta} | 8 +- Editor/BugseeEdmJavaHomeBootstrap.cs | 58 -- README.md | 33 +- Runtime/BugseePackageVersion.cs | 3 + Runtime/WrapperPolicy/GradleMavenInjection.cs | 613 ++++++++++++++++++ .../GradleMavenInjection.cs.meta | 11 + .../Plugins/Android/gradleTemplate.properties | 4 - .../Plugins/Android/mainTemplate.gradle | 57 -- .../Plugins/Android/mainTemplate.gradle.meta | 7 - .../Plugins/Android/settingsTemplate.gradle | 27 - .../Android/settingsTemplate.gradle.meta | 7 - .../BugseeMiniGame/Packages/manifest.json | 9 - .../Packages/packages-lock.json | 11 +- .../AndroidResolverDependencies.xml | 24 - .../ProjectSettings/GvhProjectSettings.xml | 6 - .../PackageManagerSettings.asset | 8 - Samples~/BugseeMiniGame/README.md | 17 +- .../GradleMavenInjectionTests.cs | 417 ++++++++++++ Tools~/scripts/update-native-sdks.sh | 24 +- package.json | 5 +- 27 files changed, 1220 insertions(+), 310 deletions(-) delete mode 100644 Editor/BugseeAndroidDependencies.xml delete mode 100644 Editor/BugseeAndroidDependencies.xml.meta create mode 100644 Editor/BugseeAndroidMavenDependencies.cs rename Editor/{BugseeEdmJavaHomeBootstrap.cs.meta => BugseeAndroidMavenDependencies.cs.meta} (60%) delete mode 100644 Editor/BugseeEdmJavaHomeBootstrap.cs create mode 100644 Runtime/WrapperPolicy/GradleMavenInjection.cs create mode 100644 Runtime/WrapperPolicy/GradleMavenInjection.cs.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle.meta delete mode 100644 Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml delete mode 100644 Samples~/BugseeMiniGame/ProjectSettings/GvhProjectSettings.xml create mode 100644 Tests~/WrapperPolicy/GradleMavenInjectionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 45adfa7..29e6003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial UPM package scaffold (`com.bugsee.unity`). -- Android native SDK via EDM4U → Maven (`com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1`). Core AAR publishes no transitives; NDK is declared explicitly. +- Android native SDK from Maven Central (`com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1`), declared in the generated Gradle project at build time. Installing is just adding the package: no EDM4U, no OpenUPM scoped registry, no resolve step, no custom Gradle templates. A Bugsee dependency the project already declares (EDM4U, custom template, local AAR) is kept, and a different version is reported. Core AAR publishes no transitives; NDK is declared explicitly. - Android Gradle plugin `4.0.8` (NDK enabled on Unity 6+ launcher templates). - iOS SPM-only native SDK: Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` + `Plugins/iOS/BugseeUnityBridge.mm` P/Invoke bridge (7.x selectors; iOS 15 deployment target). - ExceptionPipeline managed capture (`CaptureManagedExceptions`) with UnityManagedException JSON-in-reason. diff --git a/DESIGN.md b/DESIGN.md index 63cd801..5fd0cb5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -5,14 +5,14 @@ Legacy foundation: `cross/unity`. Min Unity: **2021.3**. ## Understanding -- **What:** UPM-first Unity SDK whose public C# API mirrors Android SDK **7.x** (`Bugsee` facade + public `contracts`), with platform bridges for Android (EDM4U/Maven) and iOS (remote SPM `github.com/bugsee/spm`). +- **What:** UPM-first Unity SDK whose public C# API mirrors Android SDK **7.x** (`Bugsee` facade + public `contracts`), with platform bridges for Android (Maven, declared in the generated Gradle project) and iOS (remote SPM `github.com/bugsee/spm`). - **Why:** Replace legacy `.unitypackage` / `Assets/Plugins` distribution; align with 7.0 redesign and other wrappers (Flutter 7.x pattern). - **Who:** Unity game/app developers; Bugsee maintainers. - **Non-goals (initial implementation):** OpenUPM publish, dual release repo, Asset Store `.unitypackage`, full iOS nextgen API parity deep-dive before Android bridge works, RN 6.x bridge patterns. ## Assumptions -1. Android pin: Maven `com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` via EDM4U; Gradle plugin `4.0.8`. Core publishes no transitives (no kotlin-stdlib / fragment pull); NDK must be declared. Feedback AAR stays optional. +1. Android pin: Maven `com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1`, injected into the generated `unityLibrary/build.gradle` by an `IPostGenerateGradleAndroidProject` step (no EDM4U, no custom templates); Gradle plugin `4.0.8`. Core publishes no transitives (no kotlin-stdlib / fragment pull); NDK must be declared. Feedback AAR stays optional. 2. iOS: SPM-only. Pin [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` (`IOS_SDK_VERSION` in `Tools~/versions.env`). Xcode resolves the remote package at export; no CocoaPods and no vendored xcframework. 3. Public C# surface mirrors Android 7.0; intentional C#/.NET deviations allowed (see below). 4. SDK-internal contracts (`capture` aggregators, most `contracts.internal.*` except what wrappers need for `BugseeWrapper`) are **not** public C# API. @@ -23,7 +23,7 @@ Legacy foundation: `cross/unity`. Min Unity: **2021.3**. | Decision | Choice | Alternatives | Why | |---|---|---|---| | Repo layout | UPM package at repo root | `package/` subfolder; Sentry dual-repo | Simplest Git URL install | -| Android natives | EDM4U → Maven | Vendored Unity AAR | Matches Flutter/RN 7.x; smaller git | +| Android natives | Maven, declared by a post-generate Gradle step | EDM4U (needs OpenUPM scoped registry + resolve step); vendored Unity AAR | Install is just the UPM package; matches Flutter/RN 7.x; smaller repo | | iOS natives | Remote SPM `github.com/bugsee/spm` | Vendored xcframework; CocoaPods | iOS 7.x is SPM-only | | Min Unity | 2021.3 | 2018.3 / Unity 6 only | SPM `PBXProject` APIs + practical floor | | Primary C# API | Android 7.0 mirror | Keep legacy `BugseePlugin` names | Clean 7.0 surface; legacy shims optional later | @@ -88,8 +88,8 @@ Property / key naming currently follows **Android 7.0**. When the Bugsee **iOS** ## Native packaging (already scaffolded) -- `package.json` → `com.bugsee.unity`, depends on `com.google.external-dependency-manager` -- `Editor/BugseeAndroidDependencies.xml` → Maven 7.4.1 (+ NDK, both explicit) +- `package.json` → `com.bugsee.unity`, no package dependencies +- `Editor/BugseeAndroidMavenDependencies.cs` → declares Maven 7.4.1 (+ NDK, both explicit) in the generated Gradle project; text edits in `Runtime/WrapperPolicy/GradleMavenInjection.cs`. A project-declared Bugsee dependency (EDM4U, custom template, local AAR) is kept and a version mismatch warned about - `Editor/BugseeIosSpmPostProcess.cs` → remote SPM `github.com/bugsee/spm` `7.0.0-beta6` - `Plugins/iOS/BugseeUnityBridge.mm` + `Plugins/Android/UnityManagedException.java` - `Tools~/scripts/update-native-sdks.sh` + `versions.env` @@ -149,7 +149,7 @@ Runtime/ └── BugseeBehaviour.cs # GameObject host if needed ``` -**Editor/**: EDM deps, Gradle NDK enablement, iOS SPM post-process, IL2CPP linemap + symbol orchestrator (dSYM/ELF/ProGuard). +**Editor/**: Android Maven dependency injection, Gradle NDK enablement, iOS SPM post-process, IL2CPP linemap + symbol orchestrator (dSYM/ELF/ProGuard). **Samples~/BugseeMiniGame/**: standalone Unity 2021.3 QA project (`file:../../..` → this package, relative to `Packages/`). Uses the `~` suffix so UPM does not import the minigame (avoids nested `Library` / duplicate `PlayerSettings`). Arena + Bugsee HUD + in-world stations. diff --git a/Documentation~/index.md b/Documentation~/index.md index d77d17b..893745e 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -5,9 +5,8 @@ Bug reporting, crash capture, and session replay for Unity games and apps on iOS ## Requirements - Unity **2021.3** or newer -- [External Dependency Manager for Unity (EDM4U)](https://github.com/googlesamples/unity-jar-resolver) (declared as a package dependency) - iOS builds: Xcode with Swift Package Manager support; **iOS 15+** (Bugsee 7.0.0-beta6); `bugsee-cli` on the PATH for archive-time symbol upload -- Android builds: custom Gradle templates enabled (EDM4U will prompt as needed); `bugsee-cli` on the PATH for Editor symbol upload +- Android builds: nothing to set up (no EDM4U, no custom Gradle templates; the build needs Maven Central, which Unity's Gradle project already lists); `bugsee-cli` on the PATH for Editor symbol upload ## Installation @@ -17,6 +16,13 @@ Package Manager → **+** → **Add package from git URL**: https://github.com/bugsee/bugsee-unity.git ``` +That is all: there is no scoped registry to add and no dependency-resolve step. On Android, every build +declares `com.bugsee:bugsee-android` and `com.bugsee:bugsee-android-ndk` in the generated +`unityLibrary/build.gradle` (and adds `mavenCentral()` to the project repositories if Unity's Gradle layout +lacks it). Projects that use EDM4U for other SDKs are unaffected. If the project already declares the Bugsee +Android SDK itself (EDM4U, a custom `mainTemplate.gradle`, a local AAR), that declaration wins: nothing is added +next to it, and the build log warns if its version differs from the package's pin or the NDK artifact is missing. + ## Launch ```csharp @@ -168,7 +174,7 @@ Managed error/crash **grouping** (one signature per event, Unity-primary when st ## Native dependencies -- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1` via EDM4U (Gradle plugin `4.0.8`). Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. +- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1`, declared in the generated Gradle project by `Editor/BugseeAndroidMavenDependencies.cs` (Gradle plugin `4.0.8`). Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. - **iOS:** SPM-only. Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` (iOS 15+). C bridge: `Plugins/iOS/BugseeUnityBridge.mm` + `BugseeUnityCallbacks.mm`. ### iOS bridge status diff --git a/Editor/BugseeAndroidDependencies.xml b/Editor/BugseeAndroidDependencies.xml deleted file mode 100644 index 7f5413a..0000000 --- a/Editor/BugseeAndroidDependencies.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/Editor/BugseeAndroidDependencies.xml.meta b/Editor/BugseeAndroidDependencies.xml.meta deleted file mode 100644 index 1d9080c..0000000 --- a/Editor/BugseeAndroidDependencies.xml.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 23949d29b05b41d0a32c75f444dec043 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index a8d45fb..52a933e 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -14,8 +14,8 @@ namespace Bugsee.Editor /// Instrumentation only runs on com.android.application (not unityLibrary). /// /// Plugin 4.x requires AGP ≥ 8.6 / Gradle ≥ 8.7 (Unity 6+). On Unity 2021.3 the - /// plugin apply is skipped by default. EDM deps: bugsee-android + - /// bugsee-android-ndk 7.4.1 (core publishes no transitives; NDK is explicit). + /// plugin apply is skipped by default. The runtime AARs (bugsee-android + + /// bugsee-android-ndk) are declared by . /// [InitializeOnLoad] sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport @@ -23,7 +23,7 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport public int callbackOrder => 10; public const string GradlePluginVersion = "4.0.8"; - public const string SdkVersion = "7.4.1"; + public const string SdkVersion = BugseePackageVersion.AndroidSdkVersion; const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; const string KotlinExcludeMarker = "// Bugsee: bugsee-android Gradle .module"; @@ -91,7 +91,7 @@ internal static void EnsureProjectTemplates(bool silent) { Debug.LogWarning( "[Bugsee] Bugsee Gradle plugin not applied (needs AGP 8.6+ / Unity 6+). " + - "EDM still resolves bugsee-android + bugsee-android-ndk; enable Custom Gradle templates for NDK plugin."); + "bugsee-android + bugsee-android-ndk are still declared in the generated Gradle project."); } } diff --git a/Editor/BugseeAndroidMavenDependencies.cs b/Editor/BugseeAndroidMavenDependencies.cs new file mode 100644 index 0000000..65ea6b6 --- /dev/null +++ b/Editor/BugseeAndroidMavenDependencies.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using Bugsee.WrapperPolicy; +using UnityEditor.Android; +using UnityEditor.Build; +using UnityEngine; + +namespace Bugsee.Editor +{ + /// + /// Declares the Bugsee Android SDK (bugsee-android + bugsee-android-ndk, Maven Central) in the + /// generated Gradle project, so the package needs neither EDM4U nor a custom mainTemplate.gradle. + /// The core AAR publishes no Maven transitives (empty POM and Gradle .module) and does not pull the NDK + /// artifact, so both are declared. androidx.fragment is not needed: the fragment secure-view overloads are + /// typed as Object so Unity's JNI reflection loads Bugsee without Fragment. + /// + /// The dependencies go into unityLibrary/build.gradle between marker comments (a re-run or an append export + /// does not duplicate them). A Bugsee dependency the project declares itself (EDM4U, a custom template, a + /// local AAR) is kept and nothing is added next to it; a version that differs from + /// is reported. mavenCentral() is added where the + /// generated project declares its repositories (settings.gradle dependencyResolutionManagement, or the + /// root build.gradle allprojects block) if it is missing. + /// + /// The edits themselves are (unit-tested); this class only reads and writes + /// the files. Not behind a UNITY_ANDROID guard: IPostGenerateGradleAndroidProject is defined in + /// UnityEditor.CoreModule (checked with monodis on 2021.3.45), not in the Android module's + /// UnityEditor.Android.Extensions.dll, so this compiles without Android Build Support and for every active + /// build target, and only fires for Android builds. A guard would drop the step silently whenever the + /// editor's active target is not Android when the scripts compile. + /// + sealed class BugseeAndroidMavenDependencies : IPostGenerateGradleAndroidProject + { + static readonly string[] Artifacts = + { + BugseePackageVersion.AndroidMavenArtifact, + BugseePackageVersion.AndroidNdkArtifact, + }; + + // Late, so other post-generate steps that add their own declarations run first. + public int callbackOrder => 1000; + + public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) + { + try + { + InjectDependencies(unityLibraryPath); + EnsureMavenCentral(Path.GetDirectoryName(Path.GetFullPath(unityLibraryPath))); + } + catch (Exception ex) when (!(ex is BuildFailedException)) + { + // Without these dependencies the app builds but Bugsee cannot start: fail loudly instead. + throw new BuildFailedException("[Bugsee] Could not add the Bugsee Android SDK to the Gradle project: " + ex); + } + } + + static void InjectDependencies(string unityLibraryPath) + { + string path = Path.Combine(unityLibraryPath, "build.gradle"); + if (!File.Exists(path)) + { + throw new BuildFailedException( + "[Bugsee] " + path + " not found; cannot declare com.bugsee:bugsee-android. " + + "(Kotlin DSL Gradle files are not supported.)"); + } + + string text = File.ReadAllText(path); + var result = GradleMavenInjection.ApplyDependencies( + text, + BugseePackageVersion.AndroidMavenGroup, + Artifacts, + BugseePackageVersion.AndroidSdkVersion); + + foreach (string warning in result.Warnings) + { + Debug.LogWarning("[Bugsee] " + warning); + } + + if (!string.Equals(result.Text, text, StringComparison.Ordinal)) + { + File.WriteAllText(path, result.Text); + } + + if (result.Status == GradleMavenInjection.DependencyStatus.Injected) + { + Debug.Log( + "[Bugsee] Declared " + BugseePackageVersion.AndroidMavenGroup + ":" + + string.Join("/", Artifacts) + ":" + BugseePackageVersion.AndroidSdkVersion + " in " + path); + } + } + + static void EnsureMavenCentral(string rootPath) + { + var settings = Path.Combine(rootPath, "settings.gradle"); + if (TryEnsureMavenCentral(settings, "dependencyResolutionManagement")) + { + return; + } + + var rootBuild = Path.Combine(rootPath, "build.gradle"); + if (TryEnsureMavenCentral(rootBuild, "allprojects")) + { + return; + } + + Debug.LogWarning( + "[Bugsee] Could not find the project repositories in " + settings + " or " + rootBuild + + "; make sure mavenCentral() is declared so com.bugsee:bugsee-android resolves."); + } + + static bool TryEnsureMavenCentral(string path, string outerBlock) + { + if (!File.Exists(path)) + { + return false; + } + + string text = File.ReadAllText(path); + var status = GradleMavenInjection.EnsureMavenCentral(text, outerBlock, out string updated); + if (status == GradleMavenInjection.RepositoryStatus.Inserted) + { + File.WriteAllText(path, updated); + Debug.Log("[Bugsee] Added mavenCentral() to " + outerBlock + " repositories in " + path); + } + + return status != GradleMavenInjection.RepositoryStatus.BlockNotFound; + } + } +} diff --git a/Editor/BugseeEdmJavaHomeBootstrap.cs.meta b/Editor/BugseeAndroidMavenDependencies.cs.meta similarity index 60% rename from Editor/BugseeEdmJavaHomeBootstrap.cs.meta rename to Editor/BugseeAndroidMavenDependencies.cs.meta index d9ad564..694b246 100644 --- a/Editor/BugseeEdmJavaHomeBootstrap.cs.meta +++ b/Editor/BugseeAndroidMavenDependencies.cs.meta @@ -1,11 +1,11 @@ fileFormatVersion: 2 -guid: a7c3e91b4f2d4e6a9b8c0d1e2f3a4b5c +guid: 3060c2d08734444ea1efbae907b49b78 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BugseeEdmJavaHomeBootstrap.cs b/Editor/BugseeEdmJavaHomeBootstrap.cs deleted file mode 100644 index e16bcf5..0000000 --- a/Editor/BugseeEdmJavaHomeBootstrap.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.IO; -using UnityEditor; -using UnityEngine; - -namespace Bugsee.Editor -{ - /// - /// EDM4U's Android resolver spawns Temp/PlayServicesResolverGradle/gradlew with - /// Gradle 5.1.1. That wrapper uses process JAVA_HOME / PATH java, not - /// Unity → External Tools → JDK. On machines whose default JDK is 17+, resolve fails with - /// org.codehaus.groovy.vmplugin.v7.Java7. Pin JAVA_HOME to Unity's Android OpenJDK. - /// - [InitializeOnLoad] - static class BugseeEdmJavaHomeBootstrap - { - const string SessionKey = "Bugsee.EdmJavaHomePinned"; - - static BugseeEdmJavaHomeBootstrap() - { - try - { - string playback = BuildPipeline.GetPlaybackEngineDirectory(BuildTarget.Android, BuildOptions.None); - if (string.IsNullOrEmpty(playback)) - { - return; - } - - string jdk = Path.GetFullPath(Path.Combine(playback, "OpenJDK")); - string javaBin = Path.Combine(jdk, "bin", "java"); - if (!Directory.Exists(jdk) || !File.Exists(javaBin)) - { - return; - } - - string current = Environment.GetEnvironmentVariable("JAVA_HOME"); - if (string.Equals(current, jdk, StringComparison.Ordinal)) - { - return; - } - - Environment.SetEnvironmentVariable("JAVA_HOME", jdk); - - if (!SessionState.GetBool(SessionKey, false)) - { - SessionState.SetBool(SessionKey, true); - Debug.Log( - "[Bugsee] Pinned JAVA_HOME to Unity Android OpenJDK so EDM4U's " + - $"Gradle 5.1.1 resolver can run:\n{jdk}"); - } - } - catch (Exception ex) - { - Debug.LogWarning($"[Bugsee] Could not pin JAVA_HOME for EDM4U: {ex.Message}"); - } - } - } -} diff --git a/README.md b/README.md index 50e4d2c..9ffd065 100644 --- a/README.md +++ b/README.md @@ -19,36 +19,29 @@ Pin a version/tag when available: https://github.com/bugsee/bugsee-unity.git#0.1.0 ``` -The package depends on [EDM4U](https://openupm.com/packages/com.google.external-dependency-manager/). -Add the OpenUPM scoped registry to your project’s `Packages/manifest.json` if it is not already present: - -```json -{ - "scopedRegistries": [ - { - "name": "package.openupm.com", - "url": "https://package.openupm.com", - "scopes": [ - "com.google.external-dependency-manager" - ] - } - ] -} -``` +That is the whole install: no scoped registry, no EDM4U, no dependency-resolve step and no custom +Gradle templates to enable. On Android the package declares the Bugsee SDK in the Gradle project Unity +generates for each build (see below); on iOS it adds the Bugsee Swift package to the Xcode project. + +Projects that use [EDM4U](https://github.com/googlesamples/unity-jar-resolver) for other SDKs keep working. +If the project already declares `com.bugsee:bugsee-android` itself (through EDM4U, a custom +`mainTemplate.gradle` or a local AAR), that declaration is kept, nothing is added next to it, and the build +log warns when its version differs from the one this package pins. ## Native SDKs | Platform | Integration | Current pin | |---|---|---| -| **Android** | [EDM4U](https://github.com/googlesamples/unity-jar-resolver) → Maven Central | `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` | +| **Android** | Maven Central, declared in the generated Gradle project by `BugseeAndroidMavenDependencies` | `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` | | **iOS** | Swift Package Manager + `Plugins/iOS/BugseeUnityBridge.mm` | [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` | **Android stack** | Piece | Status | |---|---| -| `bugsee-android` | Default via EDM **7.4.1** (no Maven transitives) | -| `bugsee-android-ndk` | Declared explicitly (native fatal capture for IL2CPP primary LNM) | +| `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` | Applied on launcher by `BugseeAndroidGradleSetup` on Unity 6+ (`ndk { enabled = true }`) | ### Refreshing native artifacts @@ -73,7 +66,7 @@ See [Documentation~/index.md](Documentation~/index.md) for: ``` Runtime/ # C# runtime API + asmdef -Editor/ # EDM, Gradle, iOS SPM, linemap + symbol upload +Editor/ # Android Gradle injection, iOS SPM, linemap + symbol upload Plugins/ # iOS ObjC++ bridge, Android UnityManagedException.java Native~/ios/Bugsee # Unused local SPM stub (Xcode uses github.com/bugsee/spm) Tools~/ # Version pins + maintenance scripts diff --git a/Runtime/BugseePackageVersion.cs b/Runtime/BugseePackageVersion.cs index 6c2e183..7b5be43 100644 --- a/Runtime/BugseePackageVersion.cs +++ b/Runtime/BugseePackageVersion.cs @@ -8,6 +8,9 @@ public static class BugseePackageVersion public const string Version = "0.1.0"; public const string Build = "dev"; public const string AndroidSdkVersion = "7.4.1"; + public const string AndroidMavenGroup = "com.bugsee"; + public const string AndroidMavenArtifact = "bugsee-android"; + public const string AndroidNdkArtifact = "bugsee-android-ndk"; public const string IosSdkSource = "https://github.com/bugsee/spm.git"; public const string IosSdkVersion = "7.0.0-beta6"; public const string IosSdkCommit = "75a25db8e5071a5133710f21f10265a804659de8"; diff --git a/Runtime/WrapperPolicy/GradleMavenInjection.cs b/Runtime/WrapperPolicy/GradleMavenInjection.cs new file mode 100644 index 0000000..a42b256 --- /dev/null +++ b/Runtime/WrapperPolicy/GradleMavenInjection.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Text edits on Unity's generated Gradle project that make it pull the Bugsee Android SDK from Maven + /// without EDM4U or custom Gradle templates: the module dependencies go into unityLibrary/build.gradle, + /// and mavenCentral() is added to the project repositories when the generated layout lacks it. + /// + /// Every edit is marked, so applying it again (a second build, an append export) changes nothing. + /// A Bugsee dependency the project already declares itself (EDM4U, a custom mainTemplate.gradle, a + /// local AAR) wins: nothing is injected next to it, and a version that differs from the package pin + /// is reported. + /// + public static class GradleMavenInjection + { + internal const string DependenciesBeginMarker = "// bugsee:maven-dependencies begin"; + internal const string DependenciesEndMarker = "// bugsee:maven-dependencies end"; + internal const string RepositoryMarker = "// bugsee:maven-repository"; + + const string CoreArtifactToken = "bugsee-android"; + const string NdkArtifactToken = "bugsee-android-ndk"; + + public enum DependencyStatus + { + /// The marked dependencies were inserted or upserted. + Injected, + + /// The marked dependencies were already present and current. + AlreadyInjected, + + /// The project declares Bugsee itself; nothing was injected. + DeclaredByProject, + } + + public enum RepositoryStatus + { + /// The block was not found in this file. + BlockNotFound, + + /// mavenCentral() is already declared in the block. + AlreadyPresent, + + /// mavenCentral() was inserted. + Inserted, + } + + public sealed class DependencyResult + { + public string Text; + public DependencyStatus Status; + public readonly List Warnings = new List(); + } + + /// + /// Declares :: in the + /// top-level dependencies block of a module build.gradle (a new block is appended when there + /// is none). + /// + public static DependencyResult ApplyDependencies( + string buildGradle, + string group, + IList artifacts, + string version) + { + if (buildGradle == null) + { + throw new ArgumentNullException(nameof(buildGradle)); + } + + if (string.IsNullOrEmpty(group)) + { + throw new ArgumentException("Maven group is required.", nameof(group)); + } + + if (artifacts == null || artifacts.Count == 0) + { + throw new ArgumentException("At least one artifact is required.", nameof(artifacts)); + } + + if (string.IsNullOrEmpty(version)) + { + throw new ArgumentException("Version is required.", nameof(version)); + } + + var result = new DependencyResult(); + var lines = SplitLines(buildGradle, out string newline); + bool trailingNewline = buildGradle.EndsWith("\n", StringComparison.Ordinal); + + bool hasRegion = TryFindDependencyRegion(lines, out int regionStart, out int regionEnd); + var foreign = FindProjectDeclarations(lines, hasRegion ? regionStart : -1, hasRegion ? regionEnd : -1); + + if (foreign.Count > 0) + { + result.Status = DependencyStatus.DeclaredByProject; + if (hasRegion) + { + // An earlier run injected next to what is now a project declaration: the project wins. + lines.RemoveRange(regionStart, regionEnd - regionStart + 1); + } + + ReportProjectDeclarations(result, foreign, version); + result.Text = hasRegion ? JoinLines(lines, newline, trailingNewline) : buildGradle; + return result; + } + + if (hasRegion) + { + string indent = LeadingWhitespace(lines[regionStart]); + var replacement = BuildRegion(indent, group, artifacts, version); + if (RangeEquals(lines, regionStart, regionEnd, replacement)) + { + result.Status = DependencyStatus.AlreadyInjected; + result.Text = buildGradle; + return result; + } + + lines.RemoveRange(regionStart, regionEnd - regionStart + 1); + lines.InsertRange(regionStart, replacement); + result.Status = DependencyStatus.Injected; + result.Text = JoinLines(lines, newline, trailingNewline); + return result; + } + + int open = FindTopLevelBlockOpen(lines, "dependencies"); + if (open >= 0 && IsSingleLineBlock(lines[open])) + { + // `dependencies { ... }` on one line: leave it alone and declare a block of our own. + open = -1; + } + + if (open >= 0) + { + string indent = LeadingWhitespace(lines[open]) + " "; + lines.InsertRange(open + 1, BuildRegion(indent, group, artifacts, version)); + } + else + { + if (lines.Count > 0 && lines[lines.Count - 1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + lines.Add(string.Empty); + lines.Add("dependencies {"); + lines.AddRange(BuildRegion(" ", group, artifacts, version)); + lines.Add("}"); + lines.Add(string.Empty); + trailingNewline = false; + } + + result.Status = DependencyStatus.Injected; + result.Text = JoinLines(lines, newline, trailingNewline); + return result; + } + + /// + /// Makes sure the repositories block nested in the top-level + /// (for example dependencyResolutionManagement in settings.gradle or allprojects in the + /// root build.gradle) declares mavenCentral(). + /// + public static RepositoryStatus EnsureMavenCentral(string gradle, string outerBlock, out string updated) + { + if (gradle == null) + { + throw new ArgumentNullException(nameof(gradle)); + } + + if (string.IsNullOrEmpty(outerBlock)) + { + throw new ArgumentException("Outer block name is required.", nameof(outerBlock)); + } + + updated = gradle; + var lines = SplitLines(gradle, out string newline); + int outerOpen = FindTopLevelBlockOpen(lines, outerBlock); + if (outerOpen < 0) + { + return RepositoryStatus.BlockNotFound; + } + + int outerClose = FindBlockClose(lines, outerOpen); + int reposOpen = FindChildBlockOpen(lines, outerOpen, outerClose, "repositories"); + if (reposOpen < 0) + { + return RepositoryStatus.BlockNotFound; + } + + int reposClose = FindBlockClose(lines, reposOpen); + for (int i = reposOpen + 1; i < reposClose; i++) + { + if (DeclaresMavenCentral(lines[i])) + { + return RepositoryStatus.AlreadyPresent; + } + } + + string indent = LeadingWhitespace(lines[reposOpen]) + " "; + lines.Insert(reposOpen + 1, indent + "mavenCentral() " + RepositoryMarker); + updated = JoinLines(lines, newline, gradle.EndsWith("\n", StringComparison.Ordinal)); + return RepositoryStatus.Inserted; + } + + static List BuildRegion(string indent, string group, IList artifacts, string version) + { + var region = new List { indent + DependenciesBeginMarker + " (com.bugsee.unity)" }; + for (int i = 0; i < artifacts.Count; i++) + { + region.Add(indent + "implementation '" + group + ":" + artifacts[i] + ":" + version + "'"); + } + + region.Add(indent + DependenciesEndMarker); + return region; + } + + static bool TryFindDependencyRegion(List lines, out int start, out int end) + { + start = -1; + end = -1; + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].IndexOf(DependenciesBeginMarker, StringComparison.Ordinal) < 0) + { + continue; + } + + for (int j = i; j < lines.Count; j++) + { + if (lines[j].IndexOf(DependenciesEndMarker, StringComparison.Ordinal) >= 0) + { + start = i; + end = j; + return true; + } + } + + // A begin marker without an end: treat the begin line alone as the region. + start = i; + end = i; + return true; + } + + return false; + } + + sealed class Declaration + { + public bool IsNdk; + public string Version; + public string Line; + } + + static List FindProjectDeclarations(List lines, int skipStart, int skipEnd) + { + var found = new List(); + bool inBlockComment = false; + for (int i = 0; i < lines.Count; i++) + { + string code = StripComments(lines[i], ref inBlockComment); + if (i >= skipStart && i <= skipEnd) + { + continue; + } + + int search = 0; + while (search < code.Length) + { + int at = code.IndexOf(CoreArtifactToken, search, StringComparison.Ordinal); + if (at < 0) + { + break; + } + + search = at + CoreArtifactToken.Length; + if (at > 0 && IsArtifactChar(code[at - 1])) + { + continue; + } + + var declaration = Classify(code, at); + if (declaration != null) + { + declaration.Line = lines[i].Trim(); + found.Add(declaration); + } + } + } + + return found; + } + + /// + /// bugsee-android or bugsee-android-ndk, as a Maven coordinate + /// (com.bugsee:bugsee-android:7.4.1), a map notation (name: 'bugsee-android') or a local + /// AAR (bugsee-android-7.4.1). Other artifacts (bugsee-android-feedback) are not ours to + /// manage. + /// + static Declaration Classify(string code, int at) + { + int after = at + CoreArtifactToken.Length; + bool isNdk = string.CompareOrdinal(code, at, NdkArtifactToken, 0, NdkArtifactToken.Length) == 0 && + (at + NdkArtifactToken.Length >= code.Length || + !IsArtifactChar(code[at + NdkArtifactToken.Length]) || + code[at + NdkArtifactToken.Length] == '-'); + if (isNdk) + { + after = at + NdkArtifactToken.Length; + } + else if (after < code.Length && code[after] == '-' && + !(after + 1 < code.Length && char.IsDigit(code[after + 1]))) + { + return null; + } + else if (after < code.Length && (char.IsLetterOrDigit(code[after]) || code[after] == '_')) + { + return null; + } + + return new Declaration { IsNdk = isNdk, Version = ReadVersion(code, after) }; + } + + static string ReadVersion(string code, int after) + { + if (after >= code.Length || (code[after] != ':' && code[after] != '-')) + { + return null; + } + + int start = after + 1; + int end = start; + while (end < code.Length && IsVersionChar(code[end])) + { + end++; + } + + if (end == start || (!char.IsDigit(code[start]) && code[start] != '+')) + { + return null; + } + + string version = code.Substring(start, end - start); + if (version.EndsWith(".aar", StringComparison.OrdinalIgnoreCase)) + { + version = version.Substring(0, version.Length - 4); + } + + return version; + } + + static void ReportProjectDeclarations(DependencyResult result, List found, string version) + { + bool hasCore = false; + bool hasNdk = false; + var reported = new HashSet(StringComparer.Ordinal); + foreach (var declaration in found) + { + hasCore |= !declaration.IsNdk; + hasNdk |= declaration.IsNdk; + if (declaration.Version == null || + string.Equals(declaration.Version, version, StringComparison.Ordinal) || + !reported.Add(declaration.Line)) + { + continue; + } + + result.Warnings.Add( + "The Gradle project already declares " + declaration.Line + ", which differs from the " + + version + " this com.bugsee.unity version is built and tested against. The project's " + + "declaration is kept; align it with " + version + " (or remove it and let the package add it)."); + } + + if (hasCore && !hasNdk) + { + result.Warnings.Add( + "The Gradle project declares bugsee-android but not bugsee-android-ndk, so native (NDK) " + + "crashes are not captured. Declare bugsee-android-ndk at the same version, or remove the " + + "project's Bugsee declaration and let the package add both."); + } + else if (hasNdk && !hasCore) + { + result.Warnings.Add( + "The Gradle project declares bugsee-android-ndk but not bugsee-android. Declare both, or " + + "remove the project's Bugsee declaration and let the package add both."); + } + } + + static bool DeclaresMavenCentral(string line) + { + bool inBlockComment = false; + string code = StripComments(line, ref inBlockComment); + return code.IndexOf("mavenCentral()", StringComparison.Ordinal) >= 0 || + code.IndexOf("repo.maven.apache.org/maven2", StringComparison.Ordinal) >= 0 || + code.IndexOf("repo1.maven.org/maven2", StringComparison.Ordinal) >= 0; + } + + /// Index of the line opening a depth-0 name { block, or -1. + static int FindTopLevelBlockOpen(List lines, string name) + { + int depth = 0; + bool inBlockComment = false; + for (int i = 0; i < lines.Count; i++) + { + bool wasInComment = inBlockComment; + string code = StripComments(lines[i], ref inBlockComment); + if (depth == 0 && !wasInComment && OpensBlock(code, name)) + { + return i; + } + + depth += BraceDelta(code); + } + + return -1; + } + + /// Index of the line opening name { directly inside the block opened at . + static int FindChildBlockOpen(List lines, int open, int close, string name) + { + bool inBlockComment = false; + int depth = BraceDelta(StripComments(lines[open], ref inBlockComment)); + for (int i = open + 1; i < close; i++) + { + bool wasInComment = inBlockComment; + string code = StripComments(lines[i], ref inBlockComment); + if (depth == 1 && !wasInComment && OpensBlock(code, name)) + { + return i; + } + + depth += BraceDelta(code); + } + + return -1; + } + + /// Index of the line where the block opened at closes. + static int FindBlockClose(List lines, int open) + { + int depth = 0; + bool inBlockComment = false; + for (int i = open; i < lines.Count; i++) + { + depth += BraceDelta(StripComments(lines[i], ref inBlockComment)); + if (depth <= 0) + { + return i; + } + } + + return lines.Count - 1; + } + + static bool OpensBlock(string code, string name) + { + string trimmed = code.Trim(); + if (!trimmed.StartsWith(name, StringComparison.Ordinal)) + { + return false; + } + + string rest = trimmed.Substring(name.Length).TrimStart(); + return rest.StartsWith("{", StringComparison.Ordinal); + } + + static bool IsSingleLineBlock(string line) + { + bool inBlockComment = false; + string code = StripComments(line, ref inBlockComment); + int open = code.IndexOf('{'); + return open >= 0 && code.IndexOf('}', open) >= 0; + } + + static int BraceDelta(string code) + { + int delta = 0; + foreach (char c in code) + { + if (c == '{') + { + delta++; + } + else if (c == '}') + { + delta--; + } + } + + return delta; + } + + /// + /// The code part of a line: comments removed, string literal contents kept (they hold the + /// coordinates) but their braces blanked so they do not count as structure. + /// + static string StripComments(string line, ref bool inBlockComment) + { + var sb = new StringBuilder(line.Length); + char quote = '\0'; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inBlockComment) + { + if (c == '*' && i + 1 < line.Length && line[i + 1] == '/') + { + inBlockComment = false; + i++; + } + + continue; + } + + if (quote != '\0') + { + if (c == '\\' && i + 1 < line.Length) + { + sb.Append(c).Append(line[i + 1]); + i++; + continue; + } + + if (c == quote) + { + quote = '\0'; + } + + sb.Append(c == '{' || c == '}' ? ' ' : c); + continue; + } + + if (c == '/' && i + 1 < line.Length && line[i + 1] == '/') + { + break; + } + + if (c == '/' && i + 1 < line.Length && line[i + 1] == '*') + { + inBlockComment = true; + i++; + continue; + } + + if (c == '\'' || c == '"') + { + quote = c; + } + + sb.Append(c); + } + + return sb.ToString(); + } + + static bool IsArtifactChar(char c) + { + return char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.'; + } + + static bool IsVersionChar(char c) + { + return char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_' || c == '+'; + } + + static string LeadingWhitespace(string line) + { + int i = 0; + while (i < line.Length && (line[i] == ' ' || line[i] == '\t')) + { + i++; + } + + return line.Substring(0, i); + } + + static bool RangeEquals(List lines, int start, int end, List replacement) + { + if (end - start + 1 != replacement.Count) + { + return false; + } + + for (int i = 0; i < replacement.Count; i++) + { + if (!string.Equals(lines[start + i], replacement[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + static List SplitLines(string text, out string newline) + { + newline = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; + var lines = new List(text.Replace("\r\n", "\n").Split('\n')); + if (text.EndsWith("\n", StringComparison.Ordinal)) + { + lines.RemoveAt(lines.Count - 1); + } + + return lines; + } + + static string JoinLines(List lines, string newline, bool trailingNewline) + { + string joined = string.Join(newline, lines); + return trailingNewline ? joined + newline : joined; + } + } +} diff --git a/Runtime/WrapperPolicy/GradleMavenInjection.cs.meta b/Runtime/WrapperPolicy/GradleMavenInjection.cs.meta new file mode 100644 index 0000000..c54a65b --- /dev/null +++ b/Runtime/WrapperPolicy/GradleMavenInjection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf1002f9841f44e38bf645db291d939a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties b/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties index 8ab9d27..ba3f66f 100644 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties +++ b/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties @@ -1,9 +1,5 @@ org.gradle.jvmargs=-Xmx**JVM_HEAP_SIZE**M org.gradle.parallel=true unityStreamingAssets=**STREAMING_ASSETS** -# Android Resolver Properties Start -android.useAndroidX=true -android.enableJetifier=true -# Android Resolver Properties End android.suppressUnsupportedCompileSdk=35 **ADDITIONAL_PROPERTIES** diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle b/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle deleted file mode 100644 index 5f41343..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle +++ /dev/null @@ -1,57 +0,0 @@ -apply plugin: 'com.android.library' -**APPLY_PLUGINS** - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) -// Android Resolver Dependencies Start - implementation 'com.bugsee:bugsee-android:7.4.1' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml - implementation 'com.bugsee:bugsee-android-ndk:7.4.1' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml -// Android Resolver Dependencies End -**DEPS**} - -// Android Resolver Exclusions Start -android { - packagingOptions { - exclude ('/lib/armeabi/*' + '*') - exclude ('/lib/armeabi-v7a/*' + '*') - exclude ('/lib/mips/*' + '*') - exclude ('/lib/mips64/*' + '*') - exclude ('/lib/x86/*' + '*') - exclude ('/lib/x86_64/*' + '*') - } -} -// Android Resolver Exclusions End -android { - namespace "com.unity3d.player" - ndkPath "**NDKPATH**" - compileSdkVersion **APIVERSION** - buildToolsVersion '**BUILDTOOLS**' - - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - defaultConfig { - minSdkVersion **MINSDKVERSION** - targetSdkVersion **TARGETSDKVERSION** - ndk { - abiFilters **ABIFILTERS** - } - versionCode **VERSIONCODE** - versionName '**VERSIONNAME**' - consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD** - } - - lintOptions { - abortOnError false - } - - aaptOptions { - noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ') - ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~" - }**PACKAGING_OPTIONS** -} -**IL_CPP_BUILD_SETUP** -**SOURCE_BUILD_SETUP** -**EXTERNAL_SOURCES** diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle.meta b/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle.meta deleted file mode 100644 index e78c334..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/mainTemplate.gradle.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 50146587f55664ba59e4fb75894d9f93 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle b/Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle deleted file mode 100644 index 0cc1dea..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle +++ /dev/null @@ -1,27 +0,0 @@ -pluginManagement { - repositories { - **ARTIFACTORYREPOSITORY** - gradlePluginPortal() - google() - mavenCentral() - } -} - -include ':launcher', ':unityLibrary' -**INCLUDES** - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) - repositories { - **ARTIFACTORYREPOSITORY** - google() - mavenCentral() -// Android Resolver Repos Start - def unityProjectPath = $/file:///**DIR_UNITYPROJECT**/$.replace("\\", "/") - mavenLocal() -// Android Resolver Repos End - flatDir { - dirs "${project(':unityLibrary').projectDir}/libs" - } - } -} diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle.meta b/Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle.meta deleted file mode 100644 index 58c708f..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/settingsTemplate.gradle.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 3b371fff5491a4e4999bda226b933507 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Packages/manifest.json b/Samples~/BugseeMiniGame/Packages/manifest.json index 6ddf73c..21573eb 100644 --- a/Samples~/BugseeMiniGame/Packages/manifest.json +++ b/Samples~/BugseeMiniGame/Packages/manifest.json @@ -1,13 +1,4 @@ { - "scopedRegistries": [ - { - "name": "package.openupm.com", - "url": "https://package.openupm.com", - "scopes": [ - "com.google.external-dependency-manager" - ] - } - ], "dependencies": { "com.bugsee.unity": "file:../../..", "com.unity.modules.androidjni": "1.0.0", diff --git a/Samples~/BugseeMiniGame/Packages/packages-lock.json b/Samples~/BugseeMiniGame/Packages/packages-lock.json index f3cfca2..9994df8 100644 --- a/Samples~/BugseeMiniGame/Packages/packages-lock.json +++ b/Samples~/BugseeMiniGame/Packages/packages-lock.json @@ -4,16 +4,7 @@ "version": "file:../../..", "depth": 0, "source": "local", - "dependencies": { - "com.google.external-dependency-manager": "1.2.188" - } - }, - "com.google.external-dependency-manager": { - "version": "1.2.188", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://package.openupm.com" + "dependencies": {} }, "com.unity.modules.androidjni": { "version": "1.0.0", diff --git a/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml b/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml deleted file mode 100644 index 53d79c0..0000000 --- a/Samples~/BugseeMiniGame/ProjectSettings/AndroidResolverDependencies.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - com.bugsee:bugsee-android:7.4.1 - com.bugsee:bugsee-android-ndk:7.4.1 - - - - - - - - - - - - - - - - - - - - diff --git a/Samples~/BugseeMiniGame/ProjectSettings/GvhProjectSettings.xml b/Samples~/BugseeMiniGame/ProjectSettings/GvhProjectSettings.xml deleted file mode 100644 index 08dfac7..0000000 --- a/Samples~/BugseeMiniGame/ProjectSettings/GvhProjectSettings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Samples~/BugseeMiniGame/ProjectSettings/PackageManagerSettings.asset b/Samples~/BugseeMiniGame/ProjectSettings/PackageManagerSettings.asset index ce11d03..a15cdb6 100644 --- a/Samples~/BugseeMiniGame/ProjectSettings/PackageManagerSettings.asset +++ b/Samples~/BugseeMiniGame/ProjectSettings/PackageManagerSettings.asset @@ -26,14 +26,6 @@ MonoBehaviour: m_IsDefault: 1 m_Capabilities: 7 m_ConfigSource: 0 - - m_Id: scoped:project:package.openupm.com - m_Name: package.openupm.com - m_Url: https://package.openupm.com - m_Scopes: - - com.google.external-dependency-manager - m_IsDefault: 0 - m_Capabilities: 0 - m_ConfigSource: 4 m_UserSelectedRegistryName: m_UserAddingNewScopedRegistry: 0 m_RegistryInfoDraft: diff --git a/Samples~/BugseeMiniGame/README.md b/Samples~/BugseeMiniGame/README.md index 1f49d12..e3e2db5 100644 --- a/Samples~/BugseeMiniGame/README.md +++ b/Samples~/BugseeMiniGame/README.md @@ -15,7 +15,7 @@ Every action is reachable without passing through any other. 1. Unity Hub → **Open** → select this folder (`Samples~/BugseeMiniGame`). 2. Use Unity **2021.3** (matches the SDK package). 3. Package Manager resolves `com.bugsee.unity` via `file:../../..` (repo root; path is relative to `Packages/`). -4. EDM4U (`com.google.external-dependency-manager`) comes from the OpenUPM scoped registry already listed in `Packages/manifest.json`. +4. Nothing else to install: the package has no dependencies (no EDM4U, no scoped registry). ## Set your app token @@ -63,23 +63,14 @@ SetSecureRect / SetAttribute / SetUserIdentifier / Appearance open IMGUI forms f ## Build Android -1. Switch platform to Android; resolve EDM4U dependencies when prompted. +1. Switch platform to Android. 2. Set a real Bugsee app token on `MiniGameBootstrap`. 3. Build & Run on device. -EDM pulls `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` (core publishes no transitives; NDK is explicit). +Each build declares `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` in the generated +`unityLibrary/build.gradle` (core publishes no transitives; NDK is explicit); no custom Gradle templates are needed. Gradle plugin `4.0.8` is applied on Unity 6+ launcher templates (`ndk { enabled = true }`). -### EDM resolve fails with `org.codehaus.groovy.vmplugin.v7.Java7` - -EDM4U’s Android resolver runs **Gradle 5.1.1** under `Temp/PlayServicesResolverGradle`. That process uses the editor’s environment `JAVA_HOME` / PATH `java`, **not** the JDK path shown under External Tools. - -If your Mac’s default `java` is 17 or 21, resolve crashes even when “JDK Installed with Unity” is checked. - -The `com.bugsee.unity` Editor script `BugseeEdmJavaHomeBootstrap` pins `JAVA_HOME` to Unity’s Android OpenJDK on load. After scripts recompile, run: - -**Assets → External Dependency Manager → Android Resolver → Force Resolve** - ## Character The player prefers the Meshy **rigged** FBX at `Resources/Anteater/ScarletSnout` diff --git a/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs b/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs new file mode 100644 index 0000000..02bf77a --- /dev/null +++ b/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs @@ -0,0 +1,417 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class GradleMavenInjectionTests + { + const string Group = "com.bugsee"; + const string Version = "7.4.1"; + static readonly string[] Artifacts = { "bugsee-android", "bugsee-android-ndk" }; + + const string CoreLine = " implementation 'com.bugsee:bugsee-android:7.4.1'"; + const string NdkLine = " implementation 'com.bugsee:bugsee-android-ndk:7.4.1'"; + + // unityLibrary/build.gradle as Unity 2021.3.45 generates it from the default mainTemplate.gradle + // (**DEPS** empty, so the closing brace follows the last dependency). + const string Unity2021LibraryGradle = + "apply plugin: 'com.android.library'\n" + + "\n" + + "\n" + + "dependencies {\n" + + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + "\n" + + "}\n" + + "\n" + + "android {\n" + + " namespace \"com.unity3d.player\"\n" + + " buildTypes {\n" + + " release {\n" + + " minifyEnabled false\n" + + " }\n" + + " }\n" + + "}\n"; + + // Unity 6 adds a shared script and keeps the same dependencies block shape. + const string Unity6LibraryGradle = + "apply plugin: 'com.android.library'\n" + + "apply from: '../shared/keepUnitySymbols.gradle'\n" + + "\n" + + "dependencies {\n" + + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + " implementation(name: 'some-plugin', ext:'aar')\n" + + "}\n" + + "\n" + + "android {\n" + + " namespace \"com.unity3d.player\"\n" + + "}\n"; + + const string Unity2021Settings = + "pluginManagement {\n" + + " repositories {\n" + + " \n" + + " gradlePluginPortal()\n" + + " google()\n" + + " mavenCentral()\n" + + " }\n" + + "}\n" + + "\n" + + "include ':launcher', ':unityLibrary'\n" + + "\n" + + "\n" + + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)\n" + + " repositories {\n" + + " \n" + + " google()\n" + + " mavenCentral()\n" + + " flatDir {\n" + + " dirs \"${project(':unityLibrary').projectDir}/libs\"\n" + + " }\n" + + " }\n" + + "}"; + + static GradleMavenInjection.DependencyResult Apply(string gradle) + { + return GradleMavenInjection.ApplyDependencies(gradle, Group, Artifacts, Version); + } + + static int Count(string text, string needle) + { + return Regex.Matches(text, Regex.Escape(needle)).Count; + } + + [Test] + public void Unity_2021_layout_gets_both_dependencies_inside_the_top_level_block() + { + var result = Apply(Unity2021LibraryGradle); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Warnings, Is.Empty); + string expectedBlock = + "dependencies {\n" + + " " + GradleMavenInjection.DependenciesBeginMarker + " (com.bugsee.unity)\n" + + CoreLine + "\n" + + NdkLine + "\n" + + " " + GradleMavenInjection.DependenciesEndMarker + "\n" + + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n"; + Assert.That(result.Text, Does.Contain(expectedBlock)); + Assert.That(result.Text, Does.EndWith("}\n")); + Assert.That(result.Text.Replace(expectedBlock, "dependencies {\n implementation fileTree(dir: 'libs', include: ['*.jar'])\n"), + Is.EqualTo(Unity2021LibraryGradle), "nothing else changes"); + } + + [Test] + public void Second_apply_is_a_no_op() + { + string once = Apply(Unity6LibraryGradle).Text; + var twice = Apply(once); + + Assert.That(twice.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.AlreadyInjected)); + Assert.That(twice.Text, Is.EqualTo(once)); + Assert.That(Count(once, "bugsee-android-ndk:"), Is.EqualTo(1)); + Assert.That(Count(once, "bugsee-android:"), Is.EqualTo(1)); + } + + [Test] + public void Marked_region_is_upserted_when_the_pinned_version_changes() + { + string old = GradleMavenInjection.ApplyDependencies(Unity2021LibraryGradle, Group, Artifacts, "7.3.0").Text; + var result = Apply(old); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Text, Does.Contain(CoreLine).And.Contain(NdkLine)); + Assert.That(result.Text, Does.Not.Contain("7.3.0")); + Assert.That(result.Text, Is.EqualTo(Apply(Unity2021LibraryGradle).Text)); + } + + [Test] + public void Edm4u_declaration_is_kept_and_nothing_is_added() + { + string edm = Unity2021LibraryGradle.Replace( + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n", + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + "// Android Resolver Dependencies Start\n" + + " implementation 'com.bugsee:bugsee-android:7.4.1' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml\n" + + " implementation 'com.bugsee:bugsee-android-ndk:7.4.1' // Packages/com.bugsee.unity/Editor/BugseeAndroidDependencies.xml\n" + + "// Android Resolver Dependencies End\n"); + var result = Apply(edm); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Text, Is.EqualTo(edm)); + Assert.That(result.Warnings, Is.Empty); + } + + [Test] + public void Project_declaration_with_another_version_is_kept_and_reported() + { + string custom = Unity2021LibraryGradle.Replace( + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n", + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + " implementation \"com.bugsee:bugsee-android:7.3.0\"\n" + + " implementation group: 'com.bugsee', name: 'bugsee-android-ndk', version: '7.3.0'\n"); + var result = Apply(custom); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Text, Is.EqualTo(custom)); + Assert.That(result.Warnings, Has.Count.EqualTo(1)); + Assert.That(result.Warnings[0], Does.Contain("com.bugsee:bugsee-android:7.3.0").And.Contain("7.4.1")); + } + + [Test] + public void Project_declaring_only_the_core_artifact_is_warned_about_the_missing_ndk() + { + string custom = Unity2021LibraryGradle.Replace( + "dependencies {\n", + "dependencies {\n implementation 'com.bugsee:bugsee-android:7.4.1'\n"); + var result = Apply(custom); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Text, Is.EqualTo(custom)); + Assert.That(result.Warnings, Has.Count.EqualTo(1)); + Assert.That(result.Warnings[0], Does.Contain("bugsee-android-ndk")); + } + + [Test] + public void Local_aar_in_plugins_counts_as_a_project_declaration() + { + string aar = Unity6LibraryGradle.Replace( + " implementation(name: 'some-plugin', ext:'aar')\n", + " implementation(name: 'bugsee-android-7.2.0', ext:'aar')\n" + + " implementation(name: 'bugsee-android-ndk-7.2.0', ext:'aar')\n"); + var result = Apply(aar); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Warnings, Has.Count.EqualTo(2)); + Assert.That(result.Warnings[0], Does.Contain("bugsee-android-7.2.0")); + } + + [Test] + public void Region_left_by_an_earlier_run_is_removed_when_the_project_now_declares_bugsee() + { + string injected = Apply(Unity2021LibraryGradle).Text; + string withEdm = injected.Replace( + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n", + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + " implementation 'com.bugsee:bugsee-android:7.4.1'\n" + + " implementation 'com.bugsee:bugsee-android-ndk:7.4.1'\n"); + var result = Apply(withEdm); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Text, Does.Not.Contain("bugsee:maven-dependencies")); + Assert.That(Count(result.Text, "bugsee-android:"), Is.EqualTo(1)); + Assert.That(Count(result.Text, "bugsee-android-ndk:"), Is.EqualTo(1)); + } + + [Test] + public void Commented_out_and_unrelated_bugsee_artifacts_are_not_project_declarations() + { + string gradle = Unity2021LibraryGradle.Replace( + "dependencies {\n", + "dependencies {\n" + + " // implementation 'com.bugsee:bugsee-android:7.0.0'\n" + + " /* implementation 'com.bugsee:bugsee-android-ndk:7.0.0' */\n" + + " implementation 'com.bugsee:bugsee-android-feedback:7.4.1'\n" + + " implementation 'org.example:my-bugsee-android:1.0'\n"); + var result = Apply(gradle); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Text, Does.Contain(CoreLine).And.Contain(NdkLine)); + } + + [Test] + public void Nested_dependencies_blocks_are_not_the_target() + { + string gradle = + "buildscript {\n" + + " dependencies {\n" + + " classpath 'com.android.tools.build:gradle:7.4.2'\n" + + " }\n" + + "}\n" + + "apply plugin: 'com.android.library'\n" + + "dependencies {\n" + + " implementation fileTree(dir: 'libs', include: ['*.jar'])\n" + + "}\n"; + string text = Apply(gradle).Text; + + Assert.That(text, Does.Contain( + " }\n}\napply plugin: 'com.android.library'\ndependencies {\n " + + GradleMavenInjection.DependenciesBeginMarker)); + } + + [Test] + public void Missing_or_single_line_dependencies_block_gets_a_block_of_its_own() + { + foreach (string gradle in new[] + { + "apply plugin: 'com.android.library'\n", + "apply plugin: 'com.android.library'\ndependencies { implementation 'a:b:1' }\n", + }) + { + var result = Apply(gradle); + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Text, Does.StartWith(gradle)); + Assert.That(result.Text, Does.EndWith( + "\ndependencies {\n" + + " " + GradleMavenInjection.DependenciesBeginMarker + " (com.bugsee.unity)\n" + + CoreLine + "\n" + NdkLine + "\n" + + " " + GradleMavenInjection.DependenciesEndMarker + "\n" + + "}\n")); + Assert.That(Apply(result.Text).Text, Is.EqualTo(result.Text)); + } + } + + [Test] + public void Crlf_files_stay_crlf() + { + string crlf = Unity2021LibraryGradle.Replace("\n", "\r\n"); + string text = Apply(crlf).Text; + + Assert.That(text.Replace("\r\n", string.Empty), Does.Not.Contain("\n")); + Assert.That(text, Does.Contain(CoreLine + "\r\n")); + } + + [Test] + public void Unity_2021_settings_already_declare_maven_central() + { + var status = GradleMavenInjection.EnsureMavenCentral( + Unity2021Settings, "dependencyResolutionManagement", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(updated, Is.EqualTo(Unity2021Settings)); + } + + [Test] + public void Maven_central_in_plugin_management_does_not_count_for_project_repositories() + { + string settings = Unity2021Settings.Replace( + " google()\n mavenCentral()\n flatDir {", + " google()\n // mavenCentral()\n flatDir {"); + var status = GradleMavenInjection.EnsureMavenCentral( + settings, "dependencyResolutionManagement", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Does.Contain( + " repositories {\n mavenCentral() " + GradleMavenInjection.RepositoryMarker + "\n \n google()")); + Assert.That(Count(updated, "mavenCentral()"), Is.EqualTo(3), "plugin repos + commented + inserted"); + + var again = GradleMavenInjection.EnsureMavenCentral( + updated, "dependencyResolutionManagement", out string twice); + Assert.That(again, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(twice, Is.EqualTo(updated)); + } + + [Test] + public void Root_allprojects_layout_gets_maven_central() + { + const string root = + "buildscript {\n" + + " repositories {\n" + + " google()\n" + + " mavenCentral()\n" + + " }\n" + + "}\n" + + "\n" + + "allprojects {\n" + + " repositories {\n" + + " google()\n" + + " flatDir {\n" + + " dirs \"${project(':unityLibrary').projectDir}/libs\"\n" + + " }\n" + + " }\n" + + "}\n"; + const string settings = "include ':launcher', ':unityLibrary'\n"; + + Assert.That( + GradleMavenInjection.EnsureMavenCentral(settings, "dependencyResolutionManagement", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.BlockNotFound)); + var status = GradleMavenInjection.EnsureMavenCentral(root, "allprojects", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Does.Contain( + "allprojects {\n repositories {\n mavenCentral() " + GradleMavenInjection.RepositoryMarker + + "\n google()\n")); + } + + [Test] + public void Maven_central_by_url_is_recognised() + { + const string root = + "allprojects {\n" + + " repositories {\n" + + " maven { url 'https://repo.maven.apache.org/maven2' }\n" + + " }\n" + + "}\n"; + Assert.That( + GradleMavenInjection.EnsureMavenCentral(root, "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + } + + public class AndroidMavenDependenciesSourceTests + { + static string FindRepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string RepoFile(string relative) + { + return Path.GetFullPath(Path.Combine(FindRepoRoot(), relative)); + } + + [Test] + public void Injector_is_a_post_generate_step_pinned_to_the_generated_version_constants() + { + string text = File.ReadAllText(RepoFile("Editor/BugseeAndroidMavenDependencies.cs")); + + Assert.That(text, Does.Contain(": IPostGenerateGradleAndroidProject")); + Assert.That(text, Does.Contain("BugseePackageVersion.AndroidSdkVersion")); + Assert.That(text, Does.Contain("BugseePackageVersion.AndroidMavenArtifact")); + Assert.That(text, Does.Contain("BugseePackageVersion.AndroidNdkArtifact")); + Assert.That(text, Does.Contain("GradleMavenInjection.ApplyDependencies(")); + Assert.That(text, Does.Not.Contain("#if UNITY_ANDROID")); + } + + [Test] + public void Version_constants_match_versions_env() + { + string env = File.ReadAllText(RepoFile("Tools~/versions.env")); + string constants = File.ReadAllText(RepoFile("Runtime/BugseePackageVersion.cs")); + + foreach (var (key, constant) in new[] + { + ("ANDROID_SDK_VERSION", "AndroidSdkVersion"), + ("ANDROID_MAVEN_GROUP", "AndroidMavenGroup"), + ("ANDROID_MAVEN_ARTIFACT", "AndroidMavenArtifact"), + ("ANDROID_NDK_ARTIFACT", "AndroidNdkArtifact"), + }) + { + string value = Regex.Match(env, "^" + key + "=\"([^\"]+)\"", RegexOptions.Multiline).Groups[1].Value; + Assert.That(value, Is.Not.Empty, key); + Assert.That(constants, Does.Contain("public const string " + constant + " = \"" + value + "\";")); + } + } + + [Test] + public void Package_does_not_depend_on_edm4u() + { + string package = File.ReadAllText(RepoFile("package.json")); + Assert.That(package, Does.Not.Contain("external-dependency-manager")); + Assert.That(File.Exists(RepoFile("Editor/BugseeAndroidDependencies.xml")), Is.False); + + string sample = File.ReadAllText(RepoFile("Samples~/BugseeMiniGame/Packages/manifest.json")); + Assert.That(sample, Does.Not.Contain("external-dependency-manager")); + } + } +} diff --git a/Tools~/scripts/update-native-sdks.sh b/Tools~/scripts/update-native-sdks.sh index ecffd19..c6bbf3b 100755 --- a/Tools~/scripts/update-native-sdks.sh +++ b/Tools~/scripts/update-native-sdks.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Refresh native SDK pins and artifacts for com.bugsee.unity. # -# - Regenerates Runtime/BugseePackageVersion.cs -# - Updates Editor/BugseeAndroidDependencies.xml Maven coordinates +# - Regenerates Runtime/BugseePackageVersion.cs (also the Android Maven coordinates that +# Editor/BugseeAndroidMavenDependencies.cs declares in the generated Gradle project) # - Verifies the iOS SPM tag on github.com/bugsee/spm (no local xcframework copy) set -euo pipefail @@ -19,7 +19,6 @@ fi source "${VERSIONS_FILE}" RUNTIME_VERSION_CS="${ROOT_DIR}/Runtime/BugseePackageVersion.cs" -ANDROID_DEPS_XML="${ROOT_DIR}/Editor/BugseeAndroidDependencies.xml" echo "==> Package ${BUGSEE_PACKAGE_VERSION}" echo " Android ${ANDROID_SDK_VERSION}" @@ -36,6 +35,9 @@ namespace Bugsee public const string Version = "${BUGSEE_PACKAGE_VERSION}"; public const string Build = "$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || echo dev)"; public const string AndroidSdkVersion = "${ANDROID_SDK_VERSION}"; + public const string AndroidMavenGroup = "${ANDROID_MAVEN_GROUP}"; + public const string AndroidMavenArtifact = "${ANDROID_MAVEN_ARTIFACT}"; + public const string AndroidNdkArtifact = "${ANDROID_NDK_ARTIFACT}"; public const string IosSdkSource = "${IOS_SDK_REPO}"; public const string IosSdkVersion = "${IOS_SDK_VERSION}"; public const string IosSdkCommit = "${IOS_SDK_COMMIT}"; @@ -44,22 +46,6 @@ namespace Bugsee EOF echo "Updated ${RUNTIME_VERSION_CS}" -cat > "${ANDROID_DEPS_XML}" < - - - - - - -EOF -echo "Updated ${ANDROID_DEPS_XML}" - # --- iOS SPM pin (github.com/bugsee/spm) --- if command -v git >/dev/null 2>&1; then REMOTE_SHA="$(git ls-remote "${IOS_SDK_REPO}" "refs/tags/${IOS_SDK_VERSION}" 2>/dev/null | awk '{print $1}')" diff --git a/package.json b/package.json index 8a2cbe4..3b1c575 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,5 @@ "email": "support@bugsee.com", "url": "https://www.bugsee.com" }, - "type": "library", - "dependencies": { - "com.google.external-dependency-manager": "1.2.188" - } + "type": "library" } From 8263412f5ff954b7b8e5fdf8ffd4bf3306a325db Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:25:36 +0500 Subject: [PATCH 092/157] Android: fix the JNI completion callbacks and run the 7.4 lifecycle through one state machine. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depends on the native SDK bump (chore/native-sdk-7.4.1-ios-beta6): the three-argument deleteCollectedDataOnDevice does not exist in 7.3.0, so this branch is stacked on it and must merge after it. Found by running the sample on an emulator; none of this is visible to the unit tests. - BooleanCallback1Proxy: Callback1.run is generic, and Unity hands the proxy an already-unboxed System.Boolean, so run(AndroidJavaObject) was never matched ("No such proxy method: BooleanCallback1Proxy.run(System.Boolean)"; confirmed in AndroidJavaProxy.Invoke on 2021.3.45). The existing delete-data completion therefore never worked. run(object) accepts a bool, a java Boolean and null. - LifecycleListenerProxy: with the data null, Unity's JNI could not choose between onEvent(String, AndroidJavaObject) and onEvent(String, String) and threw "Ambiguous overloads found for onEvent" at SDK launch (a null argument matches any non-value-type parameter, so both qualify). One onEvent(string, object) overload now handles a Java map, a string and null. Only verified on Unity 2021.3.45; the proxy resolution in 2022.3/Unity 6 was not checked. - Launch, Relaunch, Stop and DeleteCollectedDataOnDevice now go through AndroidLifecycleMachine (Runtime/WrapperPolicy, pure and unit-tested against a model of 7.4.1 BugseeInternal), driven on the main thread; every SDK callback is moved there. Behaviour read from the 7.4.1 bytecode: launch() while Launching/Launched is a no-op that ignores token and options and reports true; launch() while Stopping reports false synchronously; relaunch() needs Launched and otherwise returns WITHOUT a callback, and when accepted moves to Stopping inside the call; stop() while Stopping only joins; stop joiners run before a relaunch's continuation launch(). - Launch/Relaunch/Delete run one at a time in request order. One requested during a Stop, another operation, or an SDK stop the wrapper did not start is queued (non-blocking) and started when that settles, so a launch no longer races a stop. Latest intent wins: a queued Launch replaces queued Launch/Relaunch requests, a queued Relaunch folds its options into the queued launch, Stop drops them. A Delete is never skipped: a later Launch runs after it (replaces the delete-generation fence on Android, which a refused or no-op launch could defeat). - Stop bumps a stop epoch. A Launch/Relaunch result from before it is stale: it never installs the managed hooks, and a session the relaunch continuation launched anyway is stopped again; the Stop completion waits for that. - Hooks are installed before launch() (a refusal calls back inline) and reconciled for a current result. A launch() into an already running SDK warns that token and options were ignored and keeps the running hooks. - Relaunch: after relaunch() returns, a status still Launched means the SDK did not take it and the claim is released at once; any other status is ambiguous (a refusal because another thread was stopping looks like an accepted relaunch in its stop phase) and is left to the callback or the deadline. - Two-stage deadline (as on iOS). Soft (OperationTimeout, 10 s) ONLY answers the callers with a warning: Stop completions run, Launch/Relaunch/Delete completions get false. It releases no ordering: the operation stays in flight and a Launch/Relaunch requested meanwhile - including from inside that Stop completion - stays deferred until the SDK really reports (stop finished, relaunch continuation called back), because an accepted 7.4.1 relaunch's continuation launch()es whenever its slow stop finishes. Hard (HardTimeout, 60 s) resets the operation and releases the gate; callbacks after that are ignored by token, except that a Launch/Relaunch result finding the SDK running while Stop is the latest intent is stopped again. The Stop intent is set by Stop and a starting Delete and cleared only by a Launch/Relaunch that actually starts a session (LaunchStartedSession), never by a deadline or a refused launch(). Residual races (documented): a result after the hard reset with no Stop intent is ignored (hooks may be out of step until the next lifecycle call), and a Delete started after a relaunch's hard reset can overlap a continuation that launch()es later still. - Each operation takes an internal completion (Action, Stop: Action) for Task-returning wrappers. - DeleteCollectedDataOnDevice stops unless the SDK is Stopped and uses the three-argument overload with includingBundles=true: the two-argument overload does not delete finished report bundles (7.4.0 added the opt-in), including ones not yet uploaded. Behaviour change: unsent reports are now deleted too. Tests: policy tests drive the machine against a fake 7.4.1 SDK, including a relaunch refused after a concurrent stop (answered at the soft deadline, released by the hard one, the waiting delete then runs), a Launch from a soft-timed-out Stop completion staying deferred (plain slow stop, slow relaunch continuation) and, after the hard reset, not clearing the Stop intent, a refused relaunch while still Launched (released at once), late callbacks after a reset, Stop/Delete deadlines, launch deferred behind a stop, latest-intent collapsing, Stop during Relaunch sticking (in time, after the soft deadline and after the hard reset), a slow relaunch without Stop getting its hooks late, and a launch during a relaunch not skipping a waiting delete. Source tests pin run(object), the single onEvent overload, the main-thread wiring and both true arguments. Android player scripts were compiled with Unity 2021.3.45. Not exercised on a device. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Platform/Android/AndroidBridge.cs | 141 +++-- .../Android/Proxies/CallbackProxies.cs | 39 +- .../WrapperPolicy/AndroidLaunchStatePolicy.cs | 38 ++ .../AndroidLaunchStatePolicy.cs.meta | 11 + .../WrapperPolicy/AndroidLifecycleMachine.cs | 476 +++++++++++++++ .../AndroidLifecycleMachine.cs.meta | 11 + .../AndroidLaunchStatePolicyTests.cs | 542 ++++++++++++++++++ .../Bugsee.WrapperPolicy.Tests.csproj | 1 + Tests~/WrapperPolicy/PublicApiSourceTests.cs | 53 +- 9 files changed, 1227 insertions(+), 85 deletions(-) create mode 100644 Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs create mode 100644 Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs.meta create mode 100644 Runtime/WrapperPolicy/AndroidLifecycleMachine.cs create mode 100644 Runtime/WrapperPolicy/AndroidLifecycleMachine.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 615b094..909fe9f 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -66,46 +66,41 @@ AndroidJavaObject CurrentActivity() } } - public void Launch(string appToken, IDictionary options) + // Launch, Relaunch, Stop and DeleteCollectedDataOnDevice all go through one state machine on the main + // thread (AndroidLifecycleMachine: ordering, deferral behind a stop, stale results, deadlines). + AndroidLifecycleMachine _lifecycleMachine; + static readonly System.Diagnostics.Stopwatch LifecycleClock = System.Diagnostics.Stopwatch.StartNew(); + readonly HashSet _lifecycleTimers = new HashSet(); + + AndroidLifecycleMachine Lifecycle => + _lifecycleMachine ?? (_lifecycleMachine = new AndroidLifecycleMachine(new LifecycleNative(this), () => LifecycleClock.Elapsed)); + + public void Launch(string appToken, IDictionary options) => Launch(appToken, options, null); + + // completion: true when the SDK reports a launch (or was already running), false when it refused, the + // request was superseded, or no result came within AndroidLifecycleMachine.OperationTimeout. + internal void Launch(string appToken, IDictionary options, Action completion) { ManagedExceptionPayload.EnsureBuildIdentity(); - DeleteCollectedDataLaunchGeneration.BumpForLaunch(); EnsureWrapperRegistered(); InstallDefaultListeners(); - using (var activity = CurrentActivity()) - using (var map = AndroidOptionsMapper.ToJavaMap(options)) - { - _bugsee.CallStatic("launch", activity, appToken, map); - } - ExceptionPipeline.Install(this, options); - HostLogForwarder.InstallOnce(this); + MainThreadDispatcher.Run(() => Lifecycle.Launch(appToken, options, completion)); } - public void Relaunch(IDictionary options) - { - DeleteCollectedDataLaunchGeneration.BumpForLaunch(); - using (var map = AndroidOptionsMapper.ToJavaMap(options)) - { - _bugsee.CallStatic("relaunch", map); - } - ExceptionPipeline.Install(this, options); - HostLogForwarder.InstallOnce(this); - } + public void Relaunch(IDictionary options) => Relaunch(options, null); + + internal void Relaunch(IDictionary options, Action completion) => + MainThreadDispatcher.Run(() => Lifecycle.Relaunch(options, completion)); public void Stop(Action completion = null) { - AndroidManagedReportUploadFence.Invalidate(); - _openReport?.ReleaseSnapshotFiles(); - _openReport = null; - HostLogForwarder.Uninstall(); - ExceptionPipeline.Uninstall(); - if (completion == null) + MainThreadDispatcher.Run(() => { - _bugsee.CallStatic("stop"); - return; - } - - _bugsee.CallStatic("stop", new RunnableProxy(completion)); + AndroidManagedReportUploadFence.Invalidate(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + Lifecycle.Stop(completion); + }); } public bool GetLaunched() => _bugsee.CallStatic("getLaunched"); @@ -453,34 +448,78 @@ public void SetLifecycleEventListener(ILifecycleEventListener listener) public void NotifyLifecycle(string eventType) { } - public void DeleteCollectedDataOnDevice() + public void DeleteCollectedDataOnDevice() => DeleteCollectedDataOnDevice(null); + + // Stops the SDK if needed, then deletes with includingBundles=true: the two-argument overload keeps finished + // report bundles, including ones not uploaded yet, so a "delete collected data" request would leave data + // behind. Runs in request order with Launch/Relaunch/Stop, so a later Launch never cancels it. + internal void DeleteCollectedDataOnDevice(Action completion) { - AndroidManagedReportUploadFence.Invalidate(); - _openReport?.ReleaseSnapshotFiles(); - _openReport = null; - var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - if (GetLaunched()) + MainThreadDispatcher.Run(() => { - Stop(() => InvokeDeleteCollectedDataOnDevice(deleteGeneration)); - return; - } - - InvokeDeleteCollectedDataOnDevice(deleteGeneration); + AndroidManagedReportUploadFence.Invalidate(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + Lifecycle.DeleteCollectedData(completion); + }); } - void InvokeDeleteCollectedDataOnDevice(int deleteGeneration) + // The native side of the lifecycle machine. Every SDK callback is moved to the main thread. + sealed class LifecycleNative : IAndroidLifecycleNative { - if (!DeleteCollectedDataLaunchGeneration.ShouldRunDelete(deleteGeneration)) - return; + readonly AndroidBridge _bridge; + public LifecycleNative(AndroidBridge bridge) { _bridge = bridge; } - _bugsee.CallStatic( - "deleteCollectedDataOnDevice", - true, - new BooleanCallback1Proxy(success => + static Action OnMain(Action done) => ok => MainThreadDispatcher.Run(() => done(ok)); + + public BugseeStatus Status() => _bridge.GetStatus(); + + public void Launch(string appToken, IDictionary options, Action done) + { + using (var activity = _bridge.CurrentActivity()) + using (var map = AndroidOptionsMapper.ToJavaMap(options)) + _bridge._bugsee.CallStatic("launch", activity, appToken, map, new BooleanCallback1Proxy(OnMain(done))); + } + + public void Relaunch(IDictionary options, Action done) + { + using (var map = AndroidOptionsMapper.ToJavaMap(options)) + _bridge._bugsee.CallStatic("relaunch", map, new BooleanCallback1Proxy(OnMain(done))); + } + + public void Stop(Action done) => + _bridge._bugsee.CallStatic("stop", new RunnableProxy(() => MainThreadDispatcher.Run(done))); + + public void DeleteCollectedData(Action done) => + _bridge._bugsee.CallStatic("deleteCollectedDataOnDevice", true, true, new BooleanCallback1Proxy(OnMain(done))); + + public void InstallHooks(IDictionary options) + { + ExceptionPipeline.Install(_bridge, options); + HostLogForwarder.InstallOnce(_bridge); + } + + public void UninstallHooks() + { + HostLogForwarder.Uninstall(); + ExceptionPipeline.Uninstall(); + } + + public void ScheduleTick(TimeSpan delay) + { + var timers = _bridge._lifecycleTimers; + System.Threading.Timer timer = null; + timer = new System.Threading.Timer(_ => MainThreadDispatcher.Run(() => { - if (!success) - Debug.LogError("[Bugsee] deleteCollectedDataOnDevice failed."); - })); + timers.Remove(timer); + timer.Dispose(); + _bridge.Lifecycle.Tick(); + }), null, delay + TimeSpan.FromMilliseconds(50), System.Threading.Timeout.InfiniteTimeSpan); + timers.Add(timer); // keeps it alive until it fires; only touched on the main thread + } + + public void Warn(string message) => Debug.LogWarning("[Bugsee] " + message); + public void Error(Exception exception) => Debug.LogException(exception); } public IReport CreateReport() diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index 63eeda9..edd72a7 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -120,24 +120,24 @@ public LifecycleListenerProxy() { } - public void onEvent(string eventType, AndroidJavaObject data) + // One overload only. With two (AndroidJavaObject / string) Unity's JNI cannot choose when the + // data is null and throws "Ambiguous overloads found for onEvent". `object` accepts a Java + // map, a string (Android may pass the data as a JSON String) and null. + public void onEvent(string eventType, object data) { - // Unbox on the calling (often background) thread so we don't retain - // a Java local ref across the main-thread hop. object managedData = null; - try { managedData = AndroidJavaConverters.UnboxLifecycleData(data); } + try + { + var javaData = data as AndroidJavaObject; + // Unbox on the calling (often background) thread so we don't retain + // a Java local ref across the main-thread hop. + managedData = javaData != null ? AndroidJavaConverters.UnboxLifecycleData(javaData) : data; + } catch (Exception ex) { Debug.LogException(ex); } DispatchLifecycle(eventType, managedData); } - // Android may pass Object as a JSON String; Unity JNI matches that exact - // signature and otherwise logs "No such proxy method: onEvent(String,String)". - public void onEvent(string eventType, string data) - { - DispatchLifecycle(eventType, data); - } - static void DispatchLifecycle(string eventType, object managedData) { MainThreadDispatcher.Run(() => @@ -171,15 +171,24 @@ public BooleanCallback1Proxy(Action onResult) _onResult = onResult; } - public void run(AndroidJavaObject value) + // Callback1.run is generic, so the SDK passes a java.lang.Boolean; Unity hands it + // over already unboxed as System.Boolean (a run(AndroidJavaObject) overload is never matched: + // "No such proxy method: run(System.Boolean)"). `object` covers both shapes and null. + public void run(object value) { - if (value == null) + if (value is bool flag) + { + _onResult?.Invoke(flag); + return; + } + + if (value is AndroidJavaObject javaBoolean) { - _onResult?.Invoke(false); + _onResult?.Invoke(javaBoolean.Call("booleanValue")); return; } - _onResult?.Invoke(value.Call("booleanValue")); + _onResult?.Invoke(false); } } diff --git a/Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs b/Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs new file mode 100644 index 0000000..fb50f85 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs @@ -0,0 +1,38 @@ +using Bugsee.Contracts.Lifecycle; + +namespace Bugsee.WrapperPolicy +{ + /// + /// The Android 7.4.1 SDK's launch/relaunch gates (BugseeInternal), so the wrapper only claims what the SDK will + /// actually do. + /// + internal static class AndroidLaunchStatePolicy + { + public static bool IsRunning(BugseeStatus status) => + status == BugseeStatus.Launching || status == BugseeStatus.Launched; + + /// + /// relaunch() proceeds only when the SDK is Launched. While it is Launching, Stopping or Stopped it logs and + /// returns WITHOUT calling the completion, so nothing may be claimed for it. + /// + public static bool RelaunchAccepted(BugseeStatus status) => status == BugseeStatus.Launched; + + /// + /// The status read right after relaunch() returned. An accepted relaunch moves Launched -> Stopping inside the + /// call (under the SDK's lock) and always calls back later: Stopping, then Stopped, then its continuation + /// launch() (Launching, Launched). Still Launched therefore means the SDK did not take it (someone stopped it + /// between our status check and the call, and it is running again), so the claim is released now. Every + /// other status is ambiguous - a refusal because another thread was stopping looks the same as an accepted + /// relaunch in its stop phase - so the claim is kept and released by the callback or by the deadline. + /// + public static bool RelaunchClaimReleasedAfterCall(BugseeStatus statusAfterCall) => + statusAfterCall == BugseeStatus.Launched; + + /// + /// Whether THIS launch() call started a session. launch() while already Launching/Launched is a no-op that + /// ignores the token and options and still reports true, so "running afterwards" alone is not enough. + /// + public static bool LaunchStartedSession(BugseeStatus before, BugseeStatus after) => + !IsRunning(before) && IsRunning(after); + } +} diff --git a/Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs.meta b/Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs.meta new file mode 100644 index 0000000..eb45918 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidLaunchStatePolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 250a0f3302b44fbbad95323b192ef4b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs b/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs new file mode 100644 index 0000000..01e4d43 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Lifecycle; + +namespace Bugsee.WrapperPolicy +{ + /// + /// What needs from the native SDK. Every callback (done) must be delivered + /// on the thread that drives the machine (the Unity main thread); a synchronous refusal may deliver it inline, + /// from inside the call. + /// + internal interface IAndroidLifecycleNative + { + BugseeStatus Status(); + void Launch(string appToken, IDictionary options, Action done); + void Relaunch(IDictionary options, Action done); + /// stop(Runnable): starts a stop, joins one in progress, or runs done at once when Stopped. + void Stop(Action done); + void DeleteCollectedData(Action done); + void InstallHooks(IDictionary options); + void UninstallHooks(); + /// Call (on the machine's thread) after this delay. + void ScheduleTick(TimeSpan delay); + void Warn(string message); + void Error(Exception exception); + } + + /// + /// One state machine for the Android SDK lifecycle: Launch, Relaunch, Stop and DeleteCollectedDataOnDevice, all + /// driven from one thread. + /// + /// Rules: + /// - Launch, Relaunch and Delete run one at a time, in request order. One that arrives while a Stop, another of + /// them, or an SDK stop the wrapper did not start (status Stopping) is in flight is queued and started once + /// that settles - nothing blocks the caller. The latest intent wins: a queued Launch replaces queued + /// Launch/Relaunch requests after the last queued Delete, a queued Relaunch folds its options into the queued + /// Launch/Relaunch before it, and Stop drops every queued Launch/Relaunch. A Delete is never dropped or skipped + /// by a later Launch: it runs first, so it can only wipe data collected before the Launch. + /// - Stop is never queued: it starts (or joins) a native stop at once, and bumps the stop epoch. A Launch or + /// Relaunch whose result arrives after a newer stop epoch is stale: it never installs the managed hooks, and if + /// the SDK is running anyway (7.4.1 stop() only joins a relaunch's stop, whose continuation then launch()es) it + /// is stopped again. The Stop's completion waits for that, so a Stop always ends with the SDK stopped. + /// - The managed hooks are installed before launch() (a refusal is reported synchronously, from inside it) and, + /// for a current result, reconciled with the SDK's status: running installs them, a refusal removes them. + /// - Two-stage deadline, because the SDK does not always call back (relaunch() refuses silently when it is no + /// longer Launched) and a slow stop can outlive any wait: + /// - Soft, after an operation started, ONLY answers its callers, with a warning, + /// so nobody hangs: Stop completions run, Launch/Relaunch/Delete completions get false. It releases nothing + /// else - the operation stays in flight, the queue stays blocked behind it, and a Launch/Relaunch requested + /// meanwhile (also from inside such a Stop completion) stays deferred until the SDK actually reports (the + /// stop finished, the relaunch continuation called back) or the hard deadline passes. The native operation + /// may still finish after its caller got false (a Delete then still deletes); its result is applied as usual. + /// - Hard, : the operation is reset - forgotten, its stop gate released - and the + /// queue moves on. A callback after that is ignored (by token), except that a Launch/Relaunch result finding + /// the SDK running while a Stop is the latest intent (_stopIntent) is stopped again. _stopIntent is set by + /// Stop and by a Delete starting, and cleared only by a Launch or Relaunch that actually starts a session - + /// never by a deadline, and never by a launch() the SDK refused. Residual race: a result after the hard reset + /// with no Stop intent is ignored, so the managed hooks can be out of step with that session until the next + /// lifecycle call; and a Delete started after the hard reset of a relaunch can overlap a continuation that + /// launch()es later still (the Delete re-checks the status after its stop, but not during the native delete). + /// + internal sealed class AndroidLifecycleMachine + { + public static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan HardTimeout = TimeSpan.FromSeconds(60); + + enum Kind { Launch, Relaunch, Delete } + + sealed class Op + { + public Kind Kind; + public string AppToken; + public IDictionary Options; + public IDictionary PreviousHookOptions; + public readonly List> Completions = new List>(); + public int Token; + public int StopEpoch; + public TimeSpan Deadline; + public TimeSpan HardDeadline; + } + + sealed class StopOp + { + // Callers' Stop completions (answered at the soft deadline at the latest) and the machine's own + // continuations (run only when the stop really finished, or at the hard reset). + public readonly List Completions = new List(); + public readonly List Continuations = new List(); + public int Token; + public bool NativeDone; + public TimeSpan Deadline; + public TimeSpan HardDeadline; + } + + readonly IAndroidLifecycleNative _native; + readonly Func _now; + readonly LinkedList _queue = new LinkedList(); + Op _active; + StopOp _stop; + // The latest lifecycle intent is "stopped": set by Stop and by a Delete starting, cleared only by a + // Launch/Relaunch that actually starts a session. No deadline touches it. + bool _stopIntent; + int _nextToken; + int _stopEpoch; + bool _skipStoppingJoin; + IDictionary _hookOptions; + + public AndroidLifecycleMachine(IAndroidLifecycleNative native, Func now) + { + _native = native; + _now = now; + } + + public void Launch(string appToken, IDictionary options, Action completion = null) + { + var op = new Op { Kind = Kind.Launch, AppToken = appToken, Options = options }; + Add(op.Completions, completion); + // A queued Launch replaces the Launch/Relaunch requests queued after the last Delete. + while (_queue.Last != null && _queue.Last.Value.Kind != Kind.Delete) + { + Complete(_queue.Last.Value.Completions, false); + _queue.RemoveLast(); + } + _queue.AddLast(op); + Pump(); + } + + public void Relaunch(IDictionary options, Action completion = null) + { + var tail = _queue.Last?.Value; + if (tail != null && tail.Kind != Kind.Delete) + { + // Launch(a) then Relaunch(b), both waiting: launch with b. + tail.Options = options; + Add(tail.Completions, completion); + return; + } + + var op = new Op { Kind = Kind.Relaunch, Options = options }; + Add(op.Completions, completion); + _queue.AddLast(op); + Pump(); + } + + public void DeleteCollectedData(Action completion = null) + { + var tail = _queue.Last?.Value; + if (tail != null && tail.Kind == Kind.Delete) + { + Add(tail.Completions, completion); + return; + } + + var op = new Op { Kind = Kind.Delete }; + Add(op.Completions, completion); + _queue.AddLast(op); + Pump(); + } + + public void Stop(Action completion = null) + { + _stopEpoch++; + _stopIntent = true; + var dropped = new List(); + for (var node = _queue.First; node != null;) + { + var next = node.Next; + if (node.Value.Kind != Kind.Delete) + { + dropped.Add(node.Value); + _queue.Remove(node); + } + node = next; + } + + _native.UninstallHooks(); + StartStop(completion); + foreach (var op in dropped) Complete(op.Completions, false); + } + + /// Applies every deadline that has passed. Safe to call at any time. + public void Tick() + { + var now = _now(); + + // Soft: answer the callers only. Hard: reset. The operation first, so a Delete waiting on a stop that + // is reset in the same tick does not start another one. + if (_active != null && now >= _active.Deadline && _active.Completions.Count > 0) + { + _native.Warn("Bugsee." + _active.Kind + " got no result from the SDK within " + + OperationTimeout.TotalSeconds + " s; it is reported as failed, but the SDK may still " + + "complete it."); + var answered = _active.Completions.ToArray(); + _active.Completions.Clear(); + foreach (var c in answered) Run(() => c(false)); + } + + if (_active != null && now >= _active.HardDeadline) + { + _native.Warn("Bugsee." + _active.Kind + " got no result from the SDK within " + + HardTimeout.TotalSeconds + " s; the wrapper stops waiting for it."); + Settle(_active, false); + } + + if (_stop != null && now >= _stop.Deadline && _stop.Completions.Count > 0) + { + _native.Warn("Bugsee.Stop did not complete within " + OperationTimeout.TotalSeconds + + " s; its completion runs now. The SDK may still be stopping: a Launch requested now " + + "waits until it has."); + var answered = _stop.Completions.ToArray(); + _stop.Completions.Clear(); + foreach (var c in answered) Run(c); + } + + if (_stop != null && now >= _stop.HardDeadline) + { + _native.Warn("Bugsee.Stop did not complete within " + HardTimeout.TotalSeconds + + " s; the wrapper stops waiting for it."); + _skipStoppingJoin = true; + FinishStop(); + } + + Pump(); + } + + void Pump() + { + while (_active == null && _stop == null && _queue.Count > 0) + { + // 7.4.1 refuses launch() while Stopping. A stop the wrapper did not start is joined first, unless a + // join already timed out (the next operation then runs and reports what the SDK says). + if (!_skipStoppingJoin && _native.Status() == BugseeStatus.Stopping) + { + StartStop(null); + return; + } + + _skipStoppingJoin = false; + var op = _queue.First.Value; + _queue.RemoveFirst(); + op.Token = ++_nextToken; + op.StopEpoch = _stopEpoch; + op.Deadline = _now() + OperationTimeout; + op.HardDeadline = _now() + HardTimeout; + op.PreviousHookOptions = _hookOptions; + if (op.Kind == Kind.Delete) _stopIntent = true; + _active = op; + _native.ScheduleTick(OperationTimeout); + _native.ScheduleTick(HardTimeout); + switch (op.Kind) + { + case Kind.Launch: StartLaunch(op); break; + case Kind.Relaunch: StartRelaunch(op); break; + default: DeleteStep(op); break; + } + } + } + + void StartLaunch(Op op) + { + var before = _native.Status(); + if (!AndroidLaunchStatePolicy.IsRunning(before)) + { + // Hooks first: a refusal calls back inline, and the reconcile then removes them. + _hookOptions = op.Options; + _native.InstallHooks(_hookOptions); + } + else + { + _native.Warn("Bugsee.Launch: the SDK is already running, so this token and these options are " + + "ignored. Call Stop and launch from its completion to change them."); + } + + var token = op.Token; + Call(op, () => _native.Launch(op.AppToken, op.Options, ok => OnResult(token, ok))); + // Only a launch() that started a session ends a Stop intent; a refusal (still stopping) or the + // already-running no-op does not. + if (AndroidLaunchStatePolicy.LaunchStartedSession(before, _native.Status())) _stopIntent = false; + } + + void StartRelaunch(Op op) + { + if (!AndroidLaunchStatePolicy.RelaunchAccepted(_native.Status())) + { + _native.Warn("Bugsee.Relaunch ignored: the SDK only relaunches once it is Launched."); + Settle(op, false); + return; + } + + _hookOptions = op.Options; + _native.InstallHooks(_hookOptions); + var token = op.Token; + Call(op, () => _native.Relaunch(op.Options, ok => OnResult(token, ok))); + if (_active != op) return; + + // relaunch() refuses without a callback when the SDK stopped between our check and the call. + if (AndroidLaunchStatePolicy.RelaunchClaimReleasedAfterCall(_native.Status())) + { + _native.Warn("Bugsee.Relaunch was not applied: the SDK was stopped concurrently."); + _hookOptions = op.PreviousHookOptions; + Settle(op, false); + return; + } + + _stopIntent = false; // the SDK took it: its continuation launch() is wanted + } + + // The SDK must be fully Stopped before the delete, and stays stopped after it. Stop (or join the stop in + // progress), then decide again from the stop's completion: something else may have launched meanwhile. + void DeleteStep(Op op) + { + if (_active != op) return; + if (_native.Status() != BugseeStatus.Stopped) + { + _stopEpoch++; + _native.UninstallHooks(); + StartStop(null, continuation: () => DeleteStep(op)); + return; + } + + var token = op.Token; + Call(op, () => _native.DeleteCollectedData(ok => + { + if (_active == null || _active.Token != token) return; + if (!ok) _native.Warn("deleteCollectedDataOnDevice failed."); + Settle(_active, ok); + Pump(); + })); + } + + void Call(Op op, Action nativeCall) + { + try + { + nativeCall(); + } + catch (Exception e) + { + _native.Error(e); + if (_active == op) + { + Settle(op, false); + Pump(); + } + } + } + + void OnResult(int token, bool ok) + { + if (_active != null && _active.Token == token) + { + Settle(_active, ok); + Pump(); + return; + } + + // A Launch/Relaunch result after its hard reset: only a Stop intent is still honoured. + if (_stopIntent && AndroidLaunchStatePolicy.IsRunning(_native.Status())) + { + _native.Warn("A timed-out Bugsee launch started the SDK after Stop was requested; stopping it again."); + StopResurrected(); + } + } + + // The SDK runs although a Stop is the latest intent. A native stop still in progress needs nothing more. + void StopResurrected() + { + if (_stop == null || _stop.NativeDone) StartStop(null, restart: true); + } + + // Ends the active operation: reconcile the hooks, complete it, then finish a Stop that was waiting on it. + void Settle(Op op, bool ok) + { + if (_active != op) return; + _active = null; + + if (op.Kind != Kind.Delete) + { + var running = AndroidLaunchStatePolicy.IsRunning(_native.Status()); + if (op.StopEpoch != _stopEpoch) + { + // Stale: a Stop came after this started. Never re-install the hooks; stop a resurrected session. + if (running) StopResurrected(); + } + else if (running) + { + _native.InstallHooks(_hookOptions); + } + else if (!ok) + { + _hookOptions = op.PreviousHookOptions; + _native.UninstallHooks(); + if (op.Kind == Kind.Launch) + _native.Warn("The SDK reported that it did not launch (an empty or invalid token, no context, a " + + "launch made before a previous Stop finished, or an exception it rolled back). " + + "Managed capture is switched off."); + } + + } + + // In request order: this operation's waiters, then those of a Stop requested after it. + Complete(op.Completions, ok); + if (op.Kind != Kind.Delete && _stop != null && _stop.NativeDone) FinishStop(); + } + + void StartStop(Action completion, bool restart = false, Action continuation = null) + { + if (_stop != null && !restart) + { + Add(_stop.Completions, completion); + Add(_stop.Continuations, continuation); + return; + } + + if (_stop == null) + { + _stop = new StopOp { Deadline = _now() + OperationTimeout, HardDeadline = _now() + HardTimeout }; + _native.ScheduleTick(OperationTimeout); + _native.ScheduleTick(HardTimeout); + } + + Add(_stop.Completions, completion); + Add(_stop.Continuations, continuation); + var stop = _stop; + stop.NativeDone = false; + var token = stop.Token = ++_nextToken; + try + { + _native.Stop(() => OnStopDone(token)); + } + catch (Exception e) + { + _native.Error(e); + OnStopDone(token); + } + } + + void OnStopDone(int token) + { + if (_stop == null || _stop.Token != token) return; + _stop.NativeDone = true; + // A Launch/Relaunch still in flight may launch() after this stop (a relaunch's continuation): wait for + // its result, which stops it again if needed. + if (_active != null && _active.Kind != Kind.Delete) return; + FinishStop(); + Pump(); + } + + void FinishStop() + { + var stop = _stop; + _stop = null; + foreach (var c in stop.Completions) Run(c); + foreach (var c in stop.Continuations) Run(c); + } + + void Complete(List> completions, bool ok) + { + foreach (var c in completions) + { + try { c(ok); } + catch (Exception e) { _native.Error(e); } + } + } + + void Run(Action action) + { + try { action(); } + catch (Exception e) { _native.Error(e); } + } + + static void Add(List list, T item) where T : class + { + if (item != null) list.Add(item); + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs.meta b/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs.meta new file mode 100644 index 0000000..ed597b6 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a41cc59f591444fdb50ab0f03fbee642 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs b/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs new file mode 100644 index 0000000..6f6a802 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs @@ -0,0 +1,542 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Lifecycle; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + // Models the Android 7.4.1 SDK (BugseeInternal) where it matters to the wrapper: + // - launch(): Launching/Launched -> no-op, callback(true) inline, token and options ignored; Stopping -> callback(false) + // inline; otherwise -> Launching, callback later. + // - relaunch(): only when Launched (-> Stopping inside the call, stop then continuation launch(), always calls back); + // otherwise it logs and returns WITHOUT a callback. + // - stop(r): Stopped -> r at once; Stopping -> r joins; otherwise -> Stopping and r when stopped. Joiners run before + // a relaunch's continuation launch(). + sealed class FakeAndroidSdk : IAndroidLifecycleNative + { + public BugseeStatus Current = BugseeStatus.Stopped; + public readonly List Calls = new List(); + public readonly List Warnings = new List(); + public Action PendingLaunch; + public Action PendingRelaunch; + public Action PendingDelete; + public readonly List StopJoiners = new List(); + public bool HooksInstalled; + public IDictionary HookOptions; + public string RunningToken; + // Runs inside relaunch() before the SDK checks its status: another thread stopping the SDK in the gap. + public Action BeforeRelaunchCheck; + + public BugseeStatus Status() => Current; + + public void Launch(string appToken, IDictionary options, Action done) + { + Calls.Add("launch:" + appToken); + if (AndroidLaunchStatePolicy.IsRunning(Current)) { done(true); return; } + if (Current == BugseeStatus.Stopping) { done(false); return; } + if (string.IsNullOrEmpty(appToken)) { done(false); return; } + Current = BugseeStatus.Launching; + RunningToken = appToken; + PendingLaunch = done; + } + + public void Relaunch(IDictionary options, Action done) + { + Calls.Add("relaunch"); + BeforeRelaunchCheck?.Invoke(); + if (Current != BugseeStatus.Launched) return; // no callback, ever + Current = BugseeStatus.Stopping; + PendingRelaunch = done; + } + + public void Stop(Action done) + { + Calls.Add("stop"); + if (Current == BugseeStatus.Stopped) { done(); return; } + if (Current != BugseeStatus.Stopping) Current = BugseeStatus.Stopping; + StopJoiners.Add(done); + } + + public void DeleteCollectedData(Action done) + { + Calls.Add("delete:" + Current); + PendingDelete = done; + } + + public void InstallHooks(IDictionary options) { HooksInstalled = true; HookOptions = options; } + public void UninstallHooks() => HooksInstalled = false; + public void ScheduleTick(TimeSpan delay) { } + public void Warn(string message) => Warnings.Add(message); + public void Error(Exception exception) => throw exception; + + // The SDK finishes a stop: STOPPED, joiners, then a relaunch's continuation launch(). + public void FinishStop() + { + Current = BugseeStatus.Stopped; + var joiners = StopJoiners.ToArray(); + StopJoiners.Clear(); + foreach (var j in joiners) j(); + if (PendingRelaunch != null) + { + var relaunchDone = PendingRelaunch; + PendingRelaunch = null; + if (Current != BugseeStatus.Stopped) { relaunchDone(false); return; } + Current = BugseeStatus.Launching; + PendingLaunch = relaunchDone; + } + } + + // Capture started (or not): Launching -> Launched and callback(true); stopped meanwhile -> callback(false). + public void FinishLaunch() + { + var done = PendingLaunch; + PendingLaunch = null; + if (Current == BugseeStatus.Launching) { Current = BugseeStatus.Launched; done(true); } + else done(false); + } + + public void FinishDelete(bool ok) + { + var done = PendingDelete; + PendingDelete = null; + done(ok); + } + } + + public class AndroidLaunchStatePolicyTests + { + FakeAndroidSdk _sdk; + TimeSpan _now; + AndroidLifecycleMachine _machine; + static readonly IDictionary OptionsA = new Dictionary { ["a"] = 1 }; + static readonly IDictionary OptionsB = new Dictionary { ["b"] = 2 }; + + [SetUp] + public void SetUp() + { + _sdk = new FakeAndroidSdk(); + _now = TimeSpan.Zero; + _machine = new AndroidLifecycleMachine(_sdk, () => _now); + } + + void Launched(string token = "t1", IDictionary options = null) + { + _machine.Launch(token, options ?? OptionsA); + _sdk.FinishLaunch(); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Launched)); + _sdk.Calls.Clear(); + } + + void Elapse(TimeSpan by) + { + _now += by; + _machine.Tick(); + } + + [TestCase(BugseeStatus.Launched, true)] + [TestCase(BugseeStatus.Launching, false)] + [TestCase(BugseeStatus.Stopping, false)] + [TestCase(BugseeStatus.Stopped, false)] + public void Relaunch_is_accepted_only_when_launched(BugseeStatus status, bool accepted) + { + Assert.That(AndroidLaunchStatePolicy.RelaunchAccepted(status), Is.EqualTo(accepted)); + } + + [TestCase(BugseeStatus.Stopped, BugseeStatus.Launching, true)] + [TestCase(BugseeStatus.Stopped, BugseeStatus.Stopped, false)] + [TestCase(BugseeStatus.Stopping, BugseeStatus.Stopping, false)] + [TestCase(BugseeStatus.Launching, BugseeStatus.Launching, false)] + [TestCase(BugseeStatus.Launched, BugseeStatus.Launched, false)] + public void Only_a_launch_from_not_running_starts_a_session(BugseeStatus before, BugseeStatus after, bool started) + { + Assert.That(AndroidLaunchStatePolicy.LaunchStartedSession(before, after), Is.EqualTo(started)); + } + + [Test] + public void Launch_reports_the_sdk_result_and_installs_the_hooks() + { + bool? result = null; + _machine.Launch("t1", OptionsA, ok => result = ok); + Assert.That(_sdk.HooksInstalled, Is.True, "installed before launch()"); + _sdk.FinishLaunch(); + Assert.That(result, Is.True); + Assert.That(_sdk.HookOptions, Is.SameAs(OptionsA)); + } + + [Test] + public void Refused_launch_removes_the_hooks_and_reports_false() + { + bool? result = null; + _machine.Launch("", OptionsA, ok => result = ok); + Assert.That(result, Is.False); + Assert.That(_sdk.HooksInstalled, Is.False); + } + + // (A) relaunch() refused because another thread stopped the SDK between the wrapper's status check and the + // call: no callback ever. The claim must not block a pending delete forever. + [Test] + public void Relaunch_refused_after_a_concurrent_stop_releases_its_claim_by_the_deadline() + { + Launched(); + bool? relaunched = null, deleted = null; + _sdk.BeforeRelaunchCheck = () => _sdk.Current = BugseeStatus.Stopping; + _machine.Relaunch(OptionsB, ok => relaunched = ok); + _machine.DeleteCollectedData(ok => deleted = ok); + Assert.That(_sdk.Calls, Does.Not.Contain("delete:Stopped"), "waits for the relaunch result"); + + _sdk.FinishStop(); // the other thread's stop completes; the SDK never calls the relaunch back + Elapse(AndroidLifecycleMachine.OperationTimeout - TimeSpan.FromMilliseconds(1)); + Assert.That(relaunched, Is.Null); + Elapse(TimeSpan.FromMilliseconds(1)); + + Assert.That(relaunched, Is.False, "the soft deadline answers the caller with a warning"); + Assert.That(_sdk.Warnings, Has.Some.Contains("within 10 s")); + Assert.That(_sdk.Calls, Does.Not.Contain("delete:Stopped"), "but keeps the claim: a continuation may still come"); + Elapse(AndroidLifecycleMachine.HardTimeout - AndroidLifecycleMachine.OperationTimeout); + Assert.That(_sdk.Calls, Does.Contain("delete:Stopped"), "released by the hard deadline"); + _sdk.FinishDelete(true); + Assert.That(deleted, Is.True); + } + + [Test] + public void Relaunch_refused_while_still_launched_releases_its_claim_at_once() + { + // Stopped and launched again by someone else around the call: relaunch() refused, the SDK is Launched. + Launched("t1", OptionsA); + bool? relaunched = null; + var machine = new AndroidLifecycleMachine(new RefusingRelaunchSdk(_sdk), () => _now); + machine.Relaunch(OptionsB, ok => relaunched = ok); + Assert.That(relaunched, Is.False, "no deadline needed"); + Assert.That(_sdk.HookOptions, Is.Not.SameAs(OptionsB), "the hooks keep the running session's options"); + } + + // relaunch() that returns without a callback while the SDK stays Launched (refused, running again). + sealed class RefusingRelaunchSdk : IAndroidLifecycleNative + { + readonly FakeAndroidSdk _inner; + public RefusingRelaunchSdk(FakeAndroidSdk inner) { _inner = inner; } + public BugseeStatus Status() => _inner.Status(); + public void Launch(string t, IDictionary o, Action d) => _inner.Launch(t, o, d); + public void Relaunch(IDictionary o, Action d) => _inner.Calls.Add("relaunch"); + public void Stop(Action d) => _inner.Stop(d); + public void DeleteCollectedData(Action d) => _inner.DeleteCollectedData(d); + public void InstallHooks(IDictionary o) => _inner.InstallHooks(o); + public void UninstallHooks() => _inner.UninstallHooks(); + public void ScheduleTick(TimeSpan d) { } + public void Warn(string m) => _inner.Warn(m); + public void Error(Exception e) => throw e; + } + + [Test] + public void Accepted_relaunch_keeps_its_claim_until_the_sdk_reports() + { + Launched(); + bool? relaunched = null, deleted = null; + _machine.Relaunch(OptionsB, ok => relaunched = ok); + _machine.DeleteCollectedData(ok => deleted = ok); + _sdk.FinishStop(); // joiners, then the continuation launch() + Assert.That(_sdk.Calls, Does.Not.Contain("delete:Stopped"), "never between the relaunch's stop and launch()"); + _sdk.FinishLaunch(); + Assert.That(relaunched, Is.True); + // Now the delete stops the relaunched session and deletes. + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopping)); + _sdk.FinishStop(); + Assert.That(_sdk.Calls, Does.Contain("delete:Stopped")); + _sdk.FinishDelete(true); + Assert.That(deleted, Is.True); + } + + [Test] + public void Late_callback_after_the_deadline_is_ignored() + { + Launched(); + bool? relaunched = null; + _machine.Relaunch(OptionsB, ok => relaunched = ok); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(relaunched, Is.False); + bool? launched = null; + _machine.Launch("t2", OptionsA, ok => launched = ok); // waits for the relaunch's stop, then launches + _sdk.FinishStop(); // the relaunch's continuation now sees Launching and reports false, long after its reset + _sdk.FinishLaunch(); + Assert.That(relaunched, Is.False, "reported once"); + Assert.That(launched, Is.True, "the stale false did not settle the newer launch"); + Assert.That(_sdk.HooksInstalled, Is.True); + } + + [Test] + public void Stop_completion_runs_with_a_warning_when_the_sdk_never_finishes() + { + Launched(); + var stopped = 0; + _machine.Stop(() => stopped++); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(stopped, Is.EqualTo(1)); + Assert.That(_sdk.Warnings, Has.Some.Contains("Stop did not complete")); + _sdk.FinishStop(); // late + Assert.That(stopped, Is.EqualTo(1)); + } + + [Test] + public void Delete_reports_false_when_its_stop_is_slow_and_is_abandoned_at_the_hard_deadline() + { + Launched(); + var answers = new List(); + _machine.DeleteCollectedData(answers.Add); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(answers, Is.EqualTo(new[] { false }), "answered at the soft deadline"); + _sdk.FinishStop(); // late, before the hard deadline: the delete still runs + Assert.That(_sdk.Calls, Does.Contain("delete:Stopped")); + _sdk.FinishDelete(true); + Assert.That(answers, Is.EqualTo(new[] { false }), "answered once"); + + SetUp(); + Launched(); + answers.Clear(); + _machine.DeleteCollectedData(answers.Add); + Elapse(AndroidLifecycleMachine.HardTimeout); + Assert.That(answers, Is.EqualTo(new[] { false })); + _sdk.FinishStop(); // after the hard reset: nobody runs a delete any more + Assert.That(_sdk.Calls, Does.Not.Contain("delete:Stopped")); + } + + // (B) A Launch during a Stop waits for it instead of being refused by the SDK. + [Test] + public void Launch_during_a_stop_runs_when_the_stop_completes() + { + Launched(); + var order = new List(); + _machine.Stop(() => order.Add("stopped")); + _machine.Launch("t2", OptionsB, ok => order.Add("launched:" + ok)); + Assert.That(_sdk.Calls, Does.Not.Contain("launch:t2"), "not while Stopping"); + _sdk.FinishStop(); + Assert.That(_sdk.Calls, Does.Contain("launch:t2")); + _sdk.FinishLaunch(); + Assert.That(order, Is.EqualTo(new[] { "stopped", "launched:True" })); + Assert.That(_sdk.RunningToken, Is.EqualTo("t2")); + Assert.That(_sdk.HookOptions, Is.SameAs(OptionsB)); + } + + [Test] + public void Latest_intent_wins_among_queued_requests() + { + Launched(); + var results = new List(); + _machine.Stop(); + _machine.Launch("t2", OptionsA, ok => results.Add("t2:" + ok)); + _machine.Launch("t3", OptionsA, ok => results.Add("t3:" + ok)); + _machine.Relaunch(OptionsB, ok => results.Add("relaunch:" + ok)); + Assert.That(results, Is.EqualTo(new[] { "t2:False" }), "superseded at once"); + _sdk.FinishStop(); + _sdk.FinishLaunch(); + Assert.That(_sdk.Calls, Is.EqualTo(new[] { "stop", "launch:t3" })); + Assert.That(_sdk.HookOptions, Is.SameAs(OptionsB), "the relaunch's options fold into the queued launch"); + Assert.That(results, Is.EqualTo(new[] { "t2:False", "t3:True", "relaunch:True" })); + } + + [Test] + public void Stop_drops_queued_launches_but_not_a_delete() + { + Launched(); + bool? launched = null, deleted = null; + _machine.DeleteCollectedData(ok => deleted = ok); + _machine.Launch("t2", OptionsA, ok => launched = ok); + _machine.Stop(); + Assert.That(launched, Is.False); + _sdk.FinishStop(); + _sdk.FinishDelete(true); + Assert.That(deleted, Is.True); + Assert.That(_sdk.Calls, Does.Not.Contain("launch:t2")); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopped)); + } + + // A Launch never cancels a delete requested before it: the delete runs first, so it cannot wipe the new + // session and is never silently skipped. + [Test] + public void Delete_then_launch_deletes_first() + { + Launched(); + bool? deleted = null; + _machine.DeleteCollectedData(ok => deleted = ok); + _machine.Launch("t2", OptionsA); + _sdk.FinishStop(); + Assert.That(_sdk.Calls, Is.EqualTo(new[] { "stop", "delete:Stopped" })); + _sdk.FinishDelete(true); + Assert.That(deleted, Is.True); + Assert.That(_sdk.Calls, Is.EqualTo(new[] { "stop", "delete:Stopped", "launch:t2" })); + } + + // Review 2, finding 1: Stop during a Relaunch. 7.4.1 stop() only joins the relaunch's stop and the continuation + // launch()es anyway; the stale result must not re-install the hooks, and the session is stopped again. + [Test] + public void Stop_during_relaunch_sticks() + { + Launched(); + bool? relaunched = null; + var stopped = 0; + var order = new List(); + _machine.Relaunch(OptionsB, ok => { relaunched = ok; order.Add("relaunch"); }); + _machine.Stop(() => { stopped++; order.Add("stop"); }); + Assert.That(_sdk.HooksInstalled, Is.False); + + _sdk.FinishStop(); // joiners (our stop), then the continuation launch() + Assert.That(stopped, Is.EqualTo(0), "the continuation may still launch"); + _sdk.FinishLaunch(); // the resurrected session reports true + Assert.That(relaunched, Is.True); + Assert.That(_sdk.HooksInstalled, Is.False, "a stale result never installs the hooks"); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopping), "stopped again"); + _sdk.FinishStop(); + Assert.That(stopped, Is.EqualTo(1)); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopped)); + Assert.That(order, Is.EqualTo(new[] { "relaunch", "stop" })); + } + + // Review 3: the relaunch's stop outlives the soft deadline, Stop was requested meanwhile, and the 7.4.1 + // continuation then launch()es anyway. Waiters are answered at the deadline; the late result is still stopped. + [TestCase(10)] + [TestCase(61)] // after the hard reset: the Stop intent is still honoured + public void Stop_during_a_slow_relaunch_sticks_after_the_deadline(int seconds) + { + Launched(); + bool? relaunched = null; + var stopped = 0; + _machine.Relaunch(OptionsB, ok => relaunched = ok); + _machine.Stop(() => stopped++); + Elapse(TimeSpan.FromSeconds(seconds)); + Assert.That(relaunched, Is.False); + Assert.That(stopped, Is.EqualTo(1), "the Stop waiter is answered, with a warning"); + + _sdk.Calls.Clear(); + _sdk.FinishStop(); // finally stopped; the continuation launch()es: Launching + _sdk.FinishLaunch(); // late true + Assert.That(_sdk.Calls, Is.EqualTo(new[] { "stop" }), "the resurrected session is stopped again"); + Assert.That(_sdk.HooksInstalled, Is.False, "never given the hooks"); + _sdk.FinishStop(); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopped)); + Assert.That(relaunched, Is.False, "answered once"); + Assert.That(stopped, Is.EqualTo(1), "answered once"); + } + + // Review 4: the caller launches from the Stop completion, which the soft deadline runs while the relaunch's + // stop is still going. That Launch must wait for the real stop and the continuation, not start (refused) + // and clear the Stop intent so the continuation's session is taken as wanted. + [Test] + public void Launch_from_a_soft_timed_out_stop_completion_waits_for_the_relaunch_continuation() + { + Launched("t1", OptionsA); + bool? launched = null; + _machine.Relaunch(OptionsB); + _machine.Stop(() => _machine.Launch("t2", OptionsA, ok => launched = ok)); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(_sdk.Calls, Does.Not.Contain("launch:t2"), "deferred: the SDK is still stopping"); + + _sdk.FinishStop(); // stopped; the continuation launch()es the relaunch + _sdk.FinishLaunch(); // its true: stale, so stopped again + Assert.That(_sdk.Calls, Does.Not.Contain("launch:t2")); + Assert.That(_sdk.HooksInstalled, Is.False); + _sdk.FinishStop(); // now really stopped: the deferred Launch runs + Assert.That(_sdk.Calls, Does.Contain("launch:t2")); + _sdk.FinishLaunch(); + Assert.That(launched, Is.True); + Assert.That(_sdk.RunningToken, Is.EqualTo("t2")); + Assert.That(_sdk.HookOptions, Is.SameAs(OptionsA)); + } + + [Test] + public void Launch_from_a_soft_timed_out_stop_completion_waits_for_the_stop() + { + Launched("t1", OptionsA); + bool? launched = null; + _machine.Stop(() => _machine.Launch("t2", OptionsA, ok => launched = ok)); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(_sdk.Calls, Does.Not.Contain("launch:t2"), "not into Stopping, where 7.4.1 refuses it"); + _sdk.FinishStop(); + _sdk.FinishLaunch(); + Assert.That(launched, Is.True); + Assert.That(_sdk.RunningToken, Is.EqualTo("t2")); + } + + [Test] + public void Launch_from_a_stop_completion_after_the_hard_reset_does_not_clear_the_stop_intent() + { + Launched("t1", OptionsA); + bool? launched = null; + _machine.Relaunch(OptionsB); + _machine.Stop(() => _machine.Launch("t2", OptionsA, ok => launched = ok)); + Elapse(TimeSpan.FromSeconds(61)); + // Hard reset: the Launch is no longer held back, and 7.4.1 refuses it while still Stopping. + Assert.That(launched, Is.False); + + _sdk.FinishStop(); // the continuation launch()es anyway + _sdk.FinishLaunch(); // late true after the reset: the Stop intent still stands + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopping), "stopped again"); + Assert.That(_sdk.HooksInstalled, Is.False); + _sdk.FinishStop(); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopped)); + } + + [Test] + public void Slow_relaunch_without_a_stop_gets_its_hooks_when_it_finally_launches() + { + Launched("t1", OptionsA); + _machine.Relaunch(OptionsB); + Elapse(AndroidLifecycleMachine.OperationTimeout); + _sdk.FinishStop(); + _sdk.FinishLaunch(); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Launched), "not stopped: no Stop was requested"); + Assert.That(_sdk.HooksInstalled, Is.True); + Assert.That(_sdk.HookOptions, Is.SameAs(OptionsB)); + } + + // Review 2, finding 2: launch() while already running is a 7.4.1 no-op (true, token ignored). It must not + // cancel or skip a delete waiting on a relaunch, and the new token must be applied after it. + [Test] + public void Launch_during_a_relaunch_does_not_skip_a_waiting_delete() + { + Launched(); + bool? deleted = null; + _machine.Relaunch(OptionsB); + _machine.DeleteCollectedData(ok => deleted = ok); + _sdk.FinishStop(); // continuation launch(): Launching + _machine.Launch("t2", OptionsA); + Assert.That(_sdk.Calls, Does.Not.Contain("launch:t2"), "not into the relaunch's Launching"); + _sdk.FinishLaunch(); + _sdk.FinishStop(); // the delete's stop + Assert.That(_sdk.Calls, Does.Contain("delete:Stopped")); + _sdk.FinishDelete(true); + Assert.That(deleted, Is.True); + Assert.That(_sdk.RunningToken, Is.EqualTo("t2")); + } + + [Test] + public void Launch_when_already_running_warns_and_keeps_the_running_hooks() + { + Launched("t1", OptionsA); + bool? result = null; + _machine.Launch("t2", OptionsB, ok => result = ok); + Assert.That(result, Is.True, "what the SDK reports"); + Assert.That(_sdk.RunningToken, Is.EqualTo("t1")); + Assert.That(_sdk.HookOptions, Is.SameAs(OptionsA)); + Assert.That(_sdk.Warnings, Has.Some.Contains("already running")); + } + + [Test] + public void Launch_joins_a_stop_the_wrapper_did_not_start() + { + Launched(); + _sdk.Current = BugseeStatus.Stopping; // e.g. stopped from Java + _machine.Launch("t2", OptionsA); + Assert.That(_sdk.Calls, Is.EqualTo(new[] { "stop" }), "joins, does not launch into Stopping"); + _sdk.FinishStop(); + Assert.That(_sdk.Calls, Is.EqualTo(new[] { "stop", "launch:t2" })); + } + + [Test] + public void Relaunch_when_not_launched_reports_false_without_calling_the_sdk() + { + bool? result = null; + _machine.Relaunch(OptionsB, ok => result = ok); + Assert.That(result, Is.False); + Assert.That(_sdk.Calls, Is.Empty); + } + } +} diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index ad75067..49adaea 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 6bdaa0e..0e6801e 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -203,19 +203,41 @@ public void Android_does_not_use_network_launch_buffer() public void Android_delete_collected_data_stops_then_deletes() { string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); - string body = ExtractMethodBody(android, "public void DeleteCollectedDataOnDevice()"); - Assert.That(body, Does.Contain("CaptureForPendingDelete()")); - Assert.That(body, Does.Contain("InvokeDeleteCollectedDataOnDevice(deleteGeneration)")); - string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice(int deleteGeneration)"); - Assert.That(invoke, Does.Contain("ShouldRunDelete(deleteGeneration)")); - Assert.That(invoke, Does.Contain("deleteCollectedDataOnDevice")); - Assert.That(invoke, Does.Contain("true")); - Assert.That(invoke, Does.Contain("BooleanCallback1Proxy")); + string body = ExtractMethodBody(android, "internal void DeleteCollectedDataOnDevice(Action completion)"); + Assert.That(body, Does.Contain("Lifecycle.DeleteCollectedData(completion)")); + Assert.That(body, Does.Contain("MainThreadDispatcher.Run(")); string callback = File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs")); Assert.That(callback, Does.Contain("BooleanCallback1Proxy")); Assert.That(callback, Does.Contain("com.bugsee.library.contracts.common.Callback1")); - Assert.That(callback, Does.Contain("public void run(AndroidJavaObject value)")); + // Unity passes Callback1's argument already unboxed (System.Boolean), so a + // run(AndroidJavaObject) overload is never matched on device ("No such proxy method"). + Assert.That(callback, Does.Contain("public void run(object value)")); + Assert.That(callback, Does.Not.Contain("public void run(AndroidJavaObject value)")); + + // One onEvent overload: with two, Unity's JNI cannot choose when the data is null ("Ambiguous overloads"). + Assert.That(callback, Does.Contain("public void onEvent(string eventType, object data)")); + Assert.That(callback, Does.Not.Contain("public void onEvent(string eventType, string data)")); + Assert.That(callback, Does.Not.Contain("public void onEvent(string eventType, AndroidJavaObject data)")); + + // Launch/Relaunch/Stop/Delete all go through AndroidLifecycleMachine on the main thread; the ordering, + // stale-result and deadline rules are covered by AndroidLaunchStatePolicyTests. + string bridge = android; + Assert.That(ExtractMethodBody(bridge, "internal void Launch(string appToken, IDictionary options, Action completion)"), + Does.Contain("MainThreadDispatcher.Run(() => Lifecycle.Launch(appToken, options, completion))")); + Assert.That(ExtractMethodBody(bridge, "internal void Relaunch(IDictionary options, Action completion)"), + Does.Contain("Lifecycle.Relaunch(options, completion)")); + Assert.That(ExtractMethodBody(bridge, "public void Stop(Action completion = null)"), + Does.Contain("Lifecycle.Stop(completion)")); + foreach (var call in new[] { "\"launch\"", "\"relaunch\"", "\"stop\"", "\"deleteCollectedDataOnDevice\"" }) + Assert.That(System.Text.RegularExpressions.Regex.Matches(bridge, "CallStatic\\(" + System.Text.RegularExpressions.Regex.Escape(call)).Count, + Is.EqualTo(1), call + " is only called by the lifecycle machine's native side"); + // Every SDK lifecycle callback reaches the machine on the main thread. + Assert.That(System.Text.RegularExpressions.Regex.Matches(bridge, "new BooleanCallback1Proxy\\(OnMain\\(done\\)\\)").Count, Is.EqualTo(3)); + Assert.That(bridge, Does.Contain("new RunnableProxy(() => MainThreadDispatcher.Run(done))")); + Assert.That(bridge, Does.Contain("_bridge.Lifecycle.Tick()")); + Assert.That(System.Text.RegularExpressions.Regex.IsMatch(bridge, + @"""deleteCollectedDataOnDevice"",\s*true,\s*true,"), Is.True, "includingIntermediate and includingBundles"); } [Test] @@ -391,18 +413,11 @@ public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists } [Test] - public void Launch_and_relaunch_bump_delete_generation_before_native_call() + public void Launch_and_relaunch_bump_delete_generation() { + // Android orders a delete before any later Launch (AndroidLifecycleMachine), so it needs no generation. string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); - string androidLaunch = ExtractMethodBody(android, "public void Launch(string appToken, IDictionary options)"); - Assert.That(androidLaunch, Does.Contain("BumpForLaunch()")); - Assert.That(androidLaunch, Does.Contain("CallStatic(\"launch\"")); - int launchIdx = androidLaunch.IndexOf("BumpForLaunch()", StringComparison.Ordinal); - int nativeLaunchIdx = androidLaunch.IndexOf("CallStatic(\"launch\"", StringComparison.Ordinal); - Assert.That(launchIdx, Is.LessThan(nativeLaunchIdx)); - - string androidRelaunch = ExtractMethodBody(android, "public void Relaunch(IDictionary options)"); - Assert.That(androidRelaunch, Does.Contain("BumpForLaunch()")); + Assert.That(android, Does.Not.Contain("DeleteCollectedDataLaunchGeneration")); string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); string iosLaunch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); From 5a1b18fd46584687d60579a8a1c4f247be9169c6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:26:24 +0500 Subject: [PATCH 093/157] Changelog: SDK 7.4 bump, behaviour changes and bridge fixes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the pinned versions in the 0.1.0 entry (Android 7.4.1, iOS 7.0.0-beta6) and add Changed and Fixed sections covering the other PRs. The Changed section lists what an app developer can notice: unsent reports deleted by DeleteCollectedDataOnDevice, TestCrash now crashing asynchronously on the main looper, iOS IsLaunched true only in the SDK's Launched state, dropped iOS network stages, scalar attribute text form, wrapper_info, single-path lifecycle delivery and background-thread Debug.LogException now reporting. Merge after the code PRs it describes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b450214..9c082ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS SPM-only native SDK: Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` + `Plugins/iOS/BugseeUnityBridge.mm` P/Invoke bridge (7.x selectors; iOS 15 deployment target). - ExceptionPipeline managed capture (`CaptureManagedExceptions`) with UnityManagedException JSON-in-reason. - `Bugsee.LogUnhandledException` on Android and iOS. +- `Bugsee.LaunchAsync`, `RelaunchAsync` (`Task`) and `StopAsync` (`Task`) on iOS: they complete when the SDK has answered. `StopAsync` faults with `TimeoutException` when the stop could not be started (the main thread did not take the call within 5 s) and also when the SDK has not reported the stop within 10 s; in that second case the native stop may still be running, and later launches stay deferred until it reports (or 60 s pass). The existing `Launch`/`Relaunch`/`Stop` signatures are unchanged. - Editor IL2CPP linemap upload (`BugseeIl2CppLinemapUpload`) and symbol orchestrator (`BugseeSymbolUpload`: ELF / ProGuard / archive dSYM). - Hang / ANR / OOM launch-option docs and sample defaults. - iOS bridge depth: identity get/set, attribute get, appearance, report/upload labels. @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Tools~/scripts/validate-field-symbols.sh` for S1/S2 prerequisite checks. - IL2CPP native stack capture (`Il2CppNativeStack`) for managed Mode B file/line (`il2cpp_native_stack_trace`; UUID matches Bugsee symbol upload). - Nested `cause` (InnerException / Aggregate) in UnityManagedException JSON. +- Sample: configurable e2e build helper, crash scenarios (`native`, `abort`, `fatal`, `java`, `hang`) and `Tools~/scripts/e2e-android.sh`, which checks device, SDK launch and process death by pid change. - Docs: competitor matrix + Mode A/B/C for managed C# class/line (Mode B marked in progress until field-proven). - Pre-launch native wrapper registration: one process-lifetime wrapper is registered before SDK launch—Android `UnityWrapperProvider` (`ContentProvider`, `initOrder` 200), iOS bridge `+load`—instead of constructing a second wrapper from C# `Launch`. - Wrapper-channel submit: host logs and game-supplied network events go through the stored wrapper channel (Android `UnityWrapper.channelLog` / `channelAddNetwork`; iOS P/Invoke twins), not the public Bugsee log or `addNetworkEvent` APIs. @@ -30,6 +32,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Severity `0` is unset, not `VeryLow`: omitted from create-report JSON; `IReport.Severity` is `IssueSeverity?`. - Public facade: `DeleteCollectedDataOnDevice`, `CreateReport`, `AddBreadcrumb`, and `AddNetworkEvent`. +### Changed + +Behaviour changes to be aware of. 0.1.0 is not released, so these correct earlier unreleased work. + +- Native SDKs: Android `bugsee-android` / `bugsee-android-ndk` 7.4.1 and iOS `bugsee/spm` 7.0.0-beta6. +- Android `DeleteCollectedDataOnDevice` now stops the SDK whenever it is not stopped and also deletes finished + report bundles, **including reports that were not uploaded yet**. Before, it kept all finished bundles + (the native two-argument overload does not delete them) and could delete before the SDK had stopped. +- Android `TestCrash` now crashes the app with an uncaught exception thrown on the Android main looper, and + returns before the process dies. Before, the exception came back to C# as a caught `AndroidJavaException`: no + crash was reported and the app kept running. +- iOS `IsLaunched` is now true only while the SDK is in its `Launched` state (it is false during the short + `Launching` window), and `Status` reports the SDK's own state, including `Launching` and `Stopping`. + `Stop(completion)` runs the completion when the SDK reports the stop finished, and only one native stop runs at + a time: a second `Stop` or a delete during it waits for it. A `Launch` or `Relaunch` requested while a stop is + in flight is deferred until it finishes (the latest request wins; a later `Stop` or delete cancels it). A + completion is dropped when a `Launch` or `Relaunch` happened before the stop finished, and when the stop could + not be started because the main thread did not take the call within 5 s. After 10 s the call's own completion + runs with a warning while the native stop stays in flight (launches stay deferred); after 60 s the wait is + abandoned and deferred launches run. +- Android `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` are serialized in request order on the + main thread (calls from other threads are asynchronous): a `Launch` or `Relaunch` during a stop is deferred, a + `Relaunch` is ignored with a warning unless the SDK is `Launched`, a refused `Launch` no longer cancels a pending + delete, and a late launch result after a newer `Stop` is stopped again. In-flight operations answer their callers + after 10 s and are abandoned after 60 s. +- iOS wrapper-channel network events are sent with the SDK's own event types (`before`, `complete`, `cancel`, + `error`) and a Unix-millisecond timestamp. Events with a stage iOS cannot represent (`Redirect`, + `RequestTimingsReceived` and `WebSocket`) are **dropped with a warning**. +- iOS `GetAttribute` returns the text form of a scalar (`"1"` for the number 1, the string "1" and the bool true; + `"0"` for false). Android returns typed values. +- iOS launch and relaunch now send `wrapper_info` (type, version, build, metal, fps) unless the caller supplied + one. This changes the SDK's video capture to its Unity path. `fps` is `Application.targetFrameRate` (30 when + unset), never above `Screen.currentResolution.refreshRate` because the SDK uses it as a frame-skip divisor; it + is read once at launch. A caller-supplied `wrapper_info` is kept only when it is a map whose `type` is a + non-empty string; anything else is replaced, with a warning. Not yet validated on a device. +- Android lifecycle events reach C# through the wrapper only, so events fired before C# starts (auto-launch, + crash recovery) are replayed once, in order, when the listener is attached. +- `Debug.LogException` called from a background thread now reports (it used to throw `UnityException` from the + dedupe clock and report nothing). + +### Fixed + +- iOS: `SetAttribute`/`GetAttribute` crashed on string, number and bool values (`NSJSONSerialization` rejects a + bare scalar). +- iOS: the wrapper channel survives `Stop` and `DeleteCollectedDataOnDevice`; the SDK never redelivers it, so + clearing it silenced channel logs, network events and breadcrumbs for every later launch. +- iOS: `DeleteCollectedDataOnDevice` could leave the managed hooks installed over a stopped SDK while it was + still launching; `Relaunch` never marked the SDK as launched. +- iOS: launch options with a tab or other control character, nested maps or lists, enums, or NaN/Infinity now + produce valid JSON (they used to produce JSON the SDK rejected, so launch options silently fell back to + defaults). +- iOS: the managed-upload cancel state is lock-guarded; the no-SDK stub compiles. +- iOS: the injected "Bugsee Upload Symbols" Run Script failed every iOS build with "Permission denied" (the script is + written without an executable bit); it now runs through `sh` and cannot fail the build. +- iOS: dSYMs are generated for every build configuration of Unity-iPhone and UnityFramework + (`DEBUG_INFORMATION_FORMAT=dwarf-with-dsym`, `GCC_GENERATE_DEBUGGING_SYMBOLS=YES`; opt out with + `BUGSEE_NO_DSYM_FORCE` or the Bugsee > iOS menu toggle). +- iOS: the "Bugsee Upload Symbols" Run Script uploads for every Xcode configuration, Debug included (it used to + skip all but Release unless `BUGSEE_UPLOAD_ON_DEBUG=1`); `BUGSEE_SKIP_UPLOAD_ON_DEBUG=1` skips Debug. An export + appended over one made by an earlier version has its old failing phase replaced. +- Android: the pre-launch wrapper provider is injected into the generated manifest. Unity 2021.3 does not merge + a package's loose `Plugins/Android/AndroidManifest.xml`, so it was missing from the APK and early registration + never happened (a crash recovered on the next start was assembled without the wrapper). +- Android: the JNI completion callbacks work: `BooleanCallback1Proxy` never matched the unboxed `Boolean` the SDK + passes (so the delete completion never fired), and the lifecycle listener threw "Ambiguous overloads" at launch. +- Android: `Relaunch` does nothing (with a warning) unless the SDK is `Launched`, as the native call would never + call back, and a refused `Launch` no longer cancels a pending delete. +- Android: a launch the SDK refuses (made before a stop finished, empty or invalid token) switches the managed + capture hooks off, without affecting a later launch that works. +- Android: the early wrapper reports the real version and build instead of `"unknown"`. +- Android: the runtime assembly did not compile (`=> checked { ... }` is not valid C#); two scripts lacked `.meta` + files. +- Exception capture: every main-thread `Debug.LogException` was reported twice (once as an exception, once from + Unity's log text, with different signatures); the log text is now recognised as the echo of the exception + (per thread, around the inner `LogException` call), and an exception whose send failed is still reported by + the echo. +- Android symbols: the R8 mapping Unity writes as `_mapping.txt` was never uploaded; the wrong build's + symbols zip or mapping could be chosen (another configuration's files looked newer); the IL2CPP linemap was + skipped for want of a module id, which is now read from this build's symbols zip. +- `Tools~/scripts/update-native-sdks.sh` keeps the Maven comment in `BugseeAndroidDependencies.xml` and no longer + stops on an offline `git ls-remote` before writing the Android identity file. + ### Notes - Symbol upload is opt-in via `BUGSEE_APP_TOKEN` and never fails the Unity build. From 37da7824c3b2c5f8951835c067b49e9c18b9b240 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 19:05:44 +0500 Subject: [PATCH 094/157] Fix the native-pin drift test after the EDM4U removal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #41 deleted Editor/BugseeAndroidDependencies.xml and made BugseeAndroidGradleSetup.SdkVersion derive from BugseePackageVersion, so Native_sdk_pins_are_the_same_everywhere (added in #32) failed on the merged branch: it read the deleted XML and matched a literal that no longer exists. Each PR was green on its own base; the break only exists in the combination. The test now asserts what replaced them: the Maven injector declares both artifacts from BugseePackageVersion (version, core and NDK artifact), and GradleSetup takes its version from the same constant. The pins that still exist as literals (versions.env, BugseePackageVersion, the Android wrapper build.gradle, the iOS test zip) are compared as before. Policy tests: 320 pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs b/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs index ce09ef7..dcb11d3 100644 --- a/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs +++ b/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs @@ -56,10 +56,15 @@ public void Native_sdk_pins_are_the_same_everywhere() { var android = Capture("Tools~/versions.env", "ANDROID_SDK_VERSION=\"([^\"]+)\""); Assert.That(Capture("Runtime/BugseePackageVersion.cs", "AndroidSdkVersion = \"([^\"]+)\""), Is.EqualTo(android)); - Assert.That(Capture("Editor/BugseeAndroidDependencies.xml", "bugsee-android:([^\"]+)\""), Is.EqualTo(android)); - Assert.That(Capture("Editor/BugseeAndroidDependencies.xml", "bugsee-android-ndk:([^\"]+)\""), Is.EqualTo(android), - "the NDK artifact must track the core version"); - Assert.That(Capture("Editor/BugseeAndroidGradleSetup.cs", "SdkVersion = \"([^\"]+)\""), Is.EqualTo(android)); + // The Maven dependencies are declared from BugseePackageVersion at build time (no XML any more), so + // both artifacts take the one version above. + var injector = File.ReadAllText(RepoFile("Editor/BugseeAndroidMavenDependencies.cs")); + Assert.That(injector, Does.Contain("BugseePackageVersion.AndroidSdkVersion")); + Assert.That(injector, Does.Contain("BugseePackageVersion.AndroidMavenArtifact")); + Assert.That(injector, Does.Contain("BugseePackageVersion.AndroidNdkArtifact"), + "the NDK artifact must be declared with the core one"); + Assert.That(File.ReadAllText(RepoFile("Editor/BugseeAndroidGradleSetup.cs")), + Does.Contain("SdkVersion = BugseePackageVersion.AndroidSdkVersion;")); Assert.That(Capture("Tests~/Android/wrapper/build.gradle", "bugsee-android:([^\"]+)\""), Is.EqualTo(android)); var ios = Capture("Tools~/versions.env", "IOS_SDK_VERSION=\"([^\"]+)\""); From 0c2e9cd4c79ac8211c65326569deab6ed5c10779 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 19:13:15 +0500 Subject: [PATCH 095/157] Changelog: drop the removed XML from the update-native-sdks bullet; Stop is never queued on Android. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c082ca..ab75f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,8 +52,8 @@ Behaviour changes to be aware of. 0.1.0 is not released, so these correct earlie not be started because the main thread did not take the call within 5 s. After 10 s the call's own completion runs with a warning while the native stop stays in flight (launches stay deferred); after 60 s the wait is abandoned and deferred launches run. -- Android `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` are serialized in request order on the - main thread (calls from other threads are asynchronous): a `Launch` or `Relaunch` during a stop is deferred, a +- Android `Launch`, `Relaunch` and `DeleteCollectedDataOnDevice` run one at a time, in request order, on the main + thread, and `Stop` is never queued (calls from other threads are asynchronous): a `Launch` or `Relaunch` during a stop is deferred, a `Relaunch` is ignored with a warning unless the SDK is `Launched`, a refused `Launch` no longer cancels a pending delete, and a late launch result after a newer `Stop` is stopped again. In-flight operations answer their callers after 10 s and are abandoned after 60 s. @@ -111,8 +111,8 @@ Behaviour changes to be aware of. 0.1.0 is not released, so these correct earlie - Android symbols: the R8 mapping Unity writes as `_mapping.txt` was never uploaded; the wrong build's symbols zip or mapping could be chosen (another configuration's files looked newer); the IL2CPP linemap was skipped for want of a module id, which is now read from this build's symbols zip. -- `Tools~/scripts/update-native-sdks.sh` keeps the Maven comment in `BugseeAndroidDependencies.xml` and no longer - stops on an offline `git ls-remote` before writing the Android identity file. +- `Tools~/scripts/update-native-sdks.sh` no longer stops on an offline `git ls-remote` before writing the Android + identity file (`UnityWrapperIdentity.java`), so Java and C# keep agreeing on the version. ### Notes From d591ce5f86e7704380be2e8f456e8df89b5d08b5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 19:06:07 +0500 Subject: [PATCH 096/157] Fix the Editor compile errors left by the PR merges. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging #36 and #40 into cursor/upm-package-scaffold left the package uncompilable in Unity, so every iOS/Android build stopped at "Scripts have compiler errors": - BugseeIl2CppLinemapUpload.FindLineNumberMappings declared `root` twice (the build-output root, then the foreach variable): error CS0136. - BugseeSymbolUpload.cs had `using Bugsee.WrapperPolicy;` twice (CS0105). Unity is the only thing that compiles Editor/ and Runtime/, so add PackageSourceHygieneTests: it compiles the package sources with Roslyn for the Editor/iOS/Android define sets, without Unity's assemblies, and fails on errors that do not depend on resolving a Unity type (syntax errors, clashing local/member/type declarations, duplicate usings). It fails on the merged branch with the CS0136 above and passes with this fix. Found while building the sample for real-device iOS validation; with the fix the Unity export, the Xcode Debug device build and the archive-time dSYM + il2cpp-linemap upload all succeed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeIl2CppLinemapUpload.cs | 6 +- Editor/BugseeSymbolUpload.cs | 1 - .../Bugsee.WrapperPolicy.Tests.csproj | 1 + .../PackageSourceHygieneTests.cs | 85 +++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 Tests~/WrapperPolicy/PackageSourceHygieneTests.cs diff --git a/Editor/BugseeIl2CppLinemapUpload.cs b/Editor/BugseeIl2CppLinemapUpload.cs index e1947b3..f0a783b 100644 --- a/Editor/BugseeIl2CppLinemapUpload.cs +++ b/Editor/BugseeIl2CppLinemapUpload.cs @@ -145,11 +145,11 @@ static void TryWriteIosPendingLinemap(BuildReport report, string jsonPath) static string FindLineNumberMappings(BuildReport report) { var roots = new List(); - var root = BuildArtifactChoice.Il2CppOutputRoot( + var outputRoot = BuildArtifactChoice.Il2CppOutputRoot( report.summary.outputPath, BugseeSymbolUpload.OutputIsDirectory(report)); - if (root != null) + if (outputRoot != null) { - roots.Add(root); + roots.Add(outputRoot); } string bestPreferred = null; diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index fdf67be..41cab9b 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -1,6 +1,5 @@ #if UNITY_EDITOR using System; -using Bugsee.WrapperPolicy; using System.Collections.Generic; using System.IO; using System.Text; diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 49adaea..08cf8b6 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -7,6 +7,7 @@ UNITY_IOS + diff --git a/Tests~/WrapperPolicy/PackageSourceHygieneTests.cs b/Tests~/WrapperPolicy/PackageSourceHygieneTests.cs new file mode 100644 index 0000000..4b9d9d6 --- /dev/null +++ b/Tests~/WrapperPolicy/PackageSourceHygieneTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The Editor and Runtime scripts are compiled only by Unity, so a bad merge (two PRs both declaring a + /// local, both adding a using) reached the integration branch and broke every build with "Scripts have + /// compiler errors". Unity's assemblies are not available here, so this compiles the package sources + /// without them and fails only on errors that do not depend on resolving a Unity type: syntax errors, + /// clashing local/member/type declarations and duplicate usings. + /// + public class PackageSourceHygieneTests + { + static readonly HashSet ReferenceIndependent = new HashSet + { + "CS0101", // namespace already contains a definition + "CS0102", // type already contains a definition + "CS0105", // using directive appeared previously + "CS0111", // member with the same signature already defined + "CS0128", // local already defined in this scope + "CS0136", // local conflicts with a local in an enclosing scope + "CS0841", // local used before declaration + }; + + static string RepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static IEnumerable PackageSources() + { + var root = RepoRoot(); + return new[] { "Editor", "Runtime" } + .SelectMany(d => Directory.EnumerateFiles(Path.Combine(root, d), "*.cs", SearchOption.AllDirectories)) + .OrderBy(p => p, StringComparer.Ordinal); + } + + // Each Unity platform define set the package is compiled with. + static readonly string[][] DefineSets = + { + new[] { "UNITY_EDITOR", "UNITY_2021_2_OR_NEWER", "UNITY_IOS" }, + new[] { "UNITY_EDITOR", "UNITY_2021_2_OR_NEWER", "UNITY_ANDROID" }, + new[] { "UNITY_IOS", "UNITY_2021_2_OR_NEWER" }, + new[] { "UNITY_ANDROID", "UNITY_2021_2_OR_NEWER" }, + }; + + [TestCaseSource(nameof(DefineSets))] + public void PackageSourcesHaveNoReferenceIndependentCompileErrors(string[] defines) + { + var options = new CSharpParseOptions(LanguageVersion.CSharp9, preprocessorSymbols: defines); + var trees = PackageSources() + .Select(p => CSharpSyntaxTree.ParseText(File.ReadAllText(p), options, p)) + .ToList(); + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) + .Split(Path.PathSeparator) + .Select(p => MetadataReference.CreateFromFile(p)); + var compilation = CSharpCompilation.Create( + "PackageSourceHygiene", trees, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + var problems = trees.SelectMany(t => t.GetDiagnostics()) + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Concat(compilation.GetDiagnostics().Where(d => ReferenceIndependent.Contains(d.Id))) + .Select(d => d.ToString()) + .Distinct() + .ToList(); + + Assert.That(problems, Is.Empty, string.Join(Environment.NewLine, problems)); + } + } +} From ce81f2674033e043aec3a486c91701647ecde508 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 19:25:25 +0500 Subject: [PATCH 097/157] iOS: upload the SDK's dSYM too, so crashes with Bugsee frames symbolicate. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Bugsee Upload Symbols" Run Script uploaded only the app's dSYM and UnityFramework.framework.dSYM. Xcode also copies the dSYMs shipped inside the embedded xcframeworks into DWARF_DSYM_FOLDER_PATH, and the SPM Bugsee.xcframework ships Bugsee.framework.dSYM, which was never sent. On a real device (iPhone XS, iOS 18.7.9, Debug build, staging) the first crash had three Bugsee frames on the stack and stayed missing_sym, the frames shown as bare addresses ("Bugsee 0x104ba1420"). Uploading Bugsee.framework.dSYM by hand with bugsee-cli turned the same issue ready and named the frame: "NSUnknownKeyException at CaptureLayersUnity (BGSScreenCaptureEngine.m:2074)". Every Bugsee.testCrash() report has the same Bugsee frames. The script now uploads every *.dSYM bundle in DWARF_DSYM_FOLDER_PATH. The warning for a configuration without the app's/UnityFramework's dSYM (DEBUG_INFORMATION_FORMAT=dwarf) is unchanged. The script text moves from the Editor-only BugseeSymbolUpload into WrapperPolicy (IosArchiveUploadScript) so the tests can run it: they execute it with /bin/sh against a fake dSYM folder and a stub CLI and check that all dSYMs are uploaded, that the SDK dSYM alone is still sent (with the warning), that a failing CLI never fails the build, and that nothing is uploaded without a token. The generated script is otherwise byte-for-byte the previous one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeSymbolUpload.cs | 84 +--------- .../WrapperPolicy/IosArchiveUploadScript.cs | 85 ++++++++++ .../IosArchiveUploadScript.cs.meta | 11 ++ .../WrapperPolicy/IosSymbolUploadGateTests.cs | 150 +++++++++++++++--- 4 files changed, 228 insertions(+), 102 deletions(-) create mode 100644 Runtime/WrapperPolicy/IosArchiveUploadScript.cs create mode 100644 Runtime/WrapperPolicy/IosArchiveUploadScript.cs.meta diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index fdf67be..f727824 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -361,7 +361,7 @@ static void InjectArchiveUploadScript(string pathToBuiltProject) var scriptsDir = Path.Combine(pathToBuiltProject, "BugseeScripts"); Directory.CreateDirectory(scriptsDir); var scriptPath = Path.Combine(scriptsDir, "upload-symbols.sh"); - File.WriteAllText(scriptPath, BuildArchiveScript(), new UTF8Encoding(false)); + File.WriteAllText(scriptPath, IosArchiveUploadScript.Build(), new UTF8Encoding(false)); // Run it through sh: a file written from C# has no executable bit, so invoking the path directly // fails the whole build with "Permission denied" (Debug and Release alike, before the script's own @@ -377,88 +377,6 @@ static void InjectArchiveUploadScript(string pathToBuiltProject) "[Bugsee] Injected Xcode Run Script 'Bugsee Upload Symbols' for archive-time " + "dSYM + il2cpp-linemap upload (requires BUGSEE_APP_TOKEN in the Xcode/CI env)."); } - - static string BuildArchiveScript() - { - // Uses DWARF_DSYM_FOLDER_PATH / DWARF_DSYM_FILE_NAME from Xcode. - // Resolves UnityFramework UUID via dwarfdump / otool when available. - var sb = new StringBuilder(); - sb.AppendLine("#!/bin/sh"); - sb.AppendLine("set -e"); - sb.AppendLine("# Bugsee archive-time symbol upload (dSYM + il2cpp-linemap)."); - sb.AppendLine("# Never fail the Archive — swallow CLI errors."); - // Debug builds are the ones tested on devices (Unity's Development Build), and a crash from one is - // unsymbolicated without its dSYM, so every configuration uploads. Opt out per build environment. - sb.AppendLine("if [ \"${CONFIGURATION}\" = \"Debug\" ] && [ \"${BUGSEE_SKIP_UPLOAD_ON_DEBUG}\" = \"1\" ]; then"); - sb.AppendLine(" echo \"Bugsee: skip symbol upload for Debug (BUGSEE_SKIP_UPLOAD_ON_DEBUG=1).\""); - sb.AppendLine(" exit 0"); - sb.AppendLine("fi"); - sb.AppendLine("if [ -z \"${BUGSEE_APP_TOKEN}\" ]; then"); - sb.AppendLine(" echo \"Bugsee: BUGSEE_APP_TOKEN unset — skip archive symbol upload.\""); - sb.AppendLine(" exit 0"); - sb.AppendLine("fi"); - sb.AppendLine("CLI=\"${BUGSEE_CLI_PATH:-bugsee-cli}\""); - sb.AppendLine("VERSION=\"${BUGSEE_VERSION:-${MARKETING_VERSION}}\""); - sb.AppendLine("BUILD=\"${BUGSEE_BUILD:-${CURRENT_PROJECT_VERSION}}\""); - sb.AppendLine("upload() {"); - sb.AppendLine(" \"$@\" || echo \"Bugsee: upload command failed (archive continues): $*\""); - sb.AppendLine("}"); - sb.AppendLine(""); - sb.AppendLine("# --- dSYM ---"); - sb.AppendLine("if [ -n \"${DWARF_DSYM_FOLDER_PATH}\" ] && [ -n \"${DWARF_DSYM_FILE_NAME}\" ]; then"); - sb.AppendLine(" DSYM=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\""); - sb.AppendLine(" if [ -d \"${DSYM}\" ]; then"); - sb.AppendLine(" echo \"Bugsee: uploading dSYM ${DSYM}\""); - sb.AppendLine(" upload \"${CLI}\" debug-files upload \"${DSYM}\" --type dsym --version \"${VERSION}\" --build \"${BUILD}\""); - sb.AppendLine(" fi"); - sb.AppendLine("fi"); - sb.AppendLine(""); - sb.AppendLine("# Also upload UnityFramework.framework.dSYM when present beside the app dSYM."); - sb.AppendLine("if [ -n \"${DWARF_DSYM_FOLDER_PATH}\" ]; then"); - sb.AppendLine(" UF_DSYM=\"${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM\""); - sb.AppendLine(" if [ -d \"${UF_DSYM}\" ]; then"); - sb.AppendLine(" echo \"Bugsee: uploading UnityFramework dSYM ${UF_DSYM}\""); - sb.AppendLine(" upload \"${CLI}\" debug-files upload \"${UF_DSYM}\" --type dsym --version \"${VERSION}\" --build \"${BUILD}\""); - sb.AppendLine(" fi"); - sb.AppendLine("fi"); - // Unity exports Debug with DEBUG_INFORMATION_FORMAT=dwarf, which writes no dSYM: say so rather than - // finish as if the crash symbols were uploaded. - sb.AppendLine("if [ ! -d \"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\" ] && [ ! -d \"${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM\" ]; then"); - sb.AppendLine(" echo \"warning: Bugsee: no dSYM for ${CONFIGURATION} in ${DWARF_DSYM_FOLDER_PATH}; set DEBUG_INFORMATION_FORMAT=dwarf-with-dsym to symbolicate its crashes.\""); - sb.AppendLine("fi"); - sb.AppendLine(""); - sb.AppendLine("# --- il2cpp-linemap (UUID from UnityFramework binary / dSYM) ---"); - sb.AppendLine("LINEMAP=\"\""); - sb.AppendLine("if [ -f \"${PROJECT_DIR}/BugseePendingIl2CppLinemap.txt\" ]; then"); - sb.AppendLine(" LINEMAP=$(head -n1 \"${PROJECT_DIR}/BugseePendingIl2CppLinemap.txt\")"); - sb.AppendLine("fi"); - sb.AppendLine("if [ -z \"${LINEMAP}\" ] || [ ! -f \"${LINEMAP}\" ]; then"); - sb.AppendLine(" LINEMAP=$(find \"${PROJECT_DIR}\" -name LineNumberMappings.json 2>/dev/null | head -n1 || true)"); - sb.AppendLine("fi"); - sb.AppendLine("UUID=\"${BUGSEE_IL2CPP_UUIDS:-}\""); - sb.AppendLine("if [ -z \"${UUID}\" ]; then"); - sb.AppendLine(" UF_BIN=\"${BUILT_PRODUCTS_DIR}/UnityFramework.framework/UnityFramework\""); - sb.AppendLine(" if [ ! -f \"${UF_BIN}\" ]; then"); - sb.AppendLine(" UF_BIN=$(find \"${BUILT_PRODUCTS_DIR}\" -path '*/UnityFramework.framework/UnityFramework' 2>/dev/null | head -n1 || true)"); - sb.AppendLine(" fi"); - sb.AppendLine(" if [ -f \"${UF_BIN}\" ]; then"); - sb.AppendLine(" if command -v dwarfdump >/dev/null 2>&1; then"); - sb.AppendLine(" UUID=$(dwarfdump -u \"${UF_BIN}\" 2>/dev/null | sed -n 's/.*UUID: \\([^ ]*\\).*/\\1/p' | head -n1 | tr '[:upper:]' '[:lower:]' || true)"); - sb.AppendLine(" fi"); - sb.AppendLine(" if [ -z \"${UUID}\" ] && command -v otool >/dev/null 2>&1; then"); - sb.AppendLine(" UUID=$(otool -l \"${UF_BIN}\" 2>/dev/null | awk '/uuid/ {print tolower($2); exit}' || true)"); - sb.AppendLine(" fi"); - sb.AppendLine(" fi"); - sb.AppendLine("fi"); - sb.AppendLine("if [ -n \"${LINEMAP}\" ] && [ -f \"${LINEMAP}\" ] && [ -n \"${UUID}\" ]; then"); - sb.AppendLine(" echo \"Bugsee: uploading il2cpp-linemap ${LINEMAP} uuid=${UUID}\""); - sb.AppendLine(" upload \"${CLI}\" debug-files upload \"${LINEMAP}\" --type il2cpp-linemap --version \"${VERSION}\" --build \"${BUILD}\" --uuid \"${UUID}\""); - sb.AppendLine("else"); - sb.AppendLine(" echo \"Bugsee: skip il2cpp-linemap (map='${LINEMAP}' uuid='${UUID}')\""); - sb.AppendLine("fi"); - sb.AppendLine("exit 0"); - return sb.ToString(); - } } } #endif diff --git a/Runtime/WrapperPolicy/IosArchiveUploadScript.cs b/Runtime/WrapperPolicy/IosArchiveUploadScript.cs new file mode 100644 index 0000000..db5d5ee --- /dev/null +++ b/Runtime/WrapperPolicy/IosArchiveUploadScript.cs @@ -0,0 +1,85 @@ +namespace Bugsee.WrapperPolicy +{ + /// + /// The shell script behind the iOS "Bugsee Upload Symbols" Run Script phase (dSYMs + il2cpp-linemap). It never + /// fails the build: every CLI error is swallowed. + /// + public static class IosArchiveUploadScript + { + /// The script text, written to BugseeScripts/upload-symbols.sh in the exported Xcode project. + public static string Build() => Script.Replace("\r\n", "\n"); + + // Debug builds are the ones tested on devices (Unity's Development Build), and a crash from one is + // unsymbolicated without its dSYM, so every configuration uploads. Opt out per build environment. + // + // Every dSYM in DWARF_DSYM_FOLDER_PATH is uploaded, not only the app's and UnityFramework's: Xcode also copies + // there the dSYMs shipped inside the xcframeworks the app embeds, Bugsee.framework.dSYM among them. Without + // that one, a crash with a Bugsee frame on the stack (the SDK's own capture code, Bugsee.testCrash) stays + // "missing_sym" and those frames show as bare addresses. + // + // Unity exports Debug with DEBUG_INFORMATION_FORMAT=dwarf, which writes no dSYM for the app or + // UnityFramework: say so rather than finish as if the crash symbols were uploaded. + const string Script = @"#!/bin/sh +set -e +# Bugsee archive-time symbol upload (dSYMs + il2cpp-linemap). +# Never fail the Archive — swallow CLI errors. +if [ ""${CONFIGURATION}"" = ""Debug"" ] && [ ""${BUGSEE_SKIP_UPLOAD_ON_DEBUG}"" = ""1"" ]; then + echo ""Bugsee: skip symbol upload for Debug (BUGSEE_SKIP_UPLOAD_ON_DEBUG=1)."" + exit 0 +fi +if [ -z ""${BUGSEE_APP_TOKEN}"" ]; then + echo ""Bugsee: BUGSEE_APP_TOKEN unset — skip archive symbol upload."" + exit 0 +fi +CLI=""${BUGSEE_CLI_PATH:-bugsee-cli}"" +VERSION=""${BUGSEE_VERSION:-${MARKETING_VERSION}}"" +BUILD=""${BUGSEE_BUILD:-${CURRENT_PROJECT_VERSION}}"" +upload() { + ""$@"" || echo ""Bugsee: upload command failed (archive continues): $*"" +} + +# --- dSYMs: the app, UnityFramework and the embedded xcframeworks' (Bugsee.framework.dSYM) --- +if [ -n ""${DWARF_DSYM_FOLDER_PATH}"" ] && [ -d ""${DWARF_DSYM_FOLDER_PATH}"" ]; then + for DSYM in ""${DWARF_DSYM_FOLDER_PATH}""/*.dSYM; do + [ -d ""${DSYM}"" ] || continue + echo ""Bugsee: uploading dSYM ${DSYM}"" + upload ""${CLI}"" debug-files upload ""${DSYM}"" --type dsym --version ""${VERSION}"" --build ""${BUILD}"" + done +fi +if [ ! -d ""${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}"" ] && [ ! -d ""${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM"" ]; then + echo ""warning: Bugsee: no dSYM for ${CONFIGURATION} in ${DWARF_DSYM_FOLDER_PATH}; set DEBUG_INFORMATION_FORMAT=dwarf-with-dsym to symbolicate its crashes."" +fi + +# --- il2cpp-linemap (UUID from UnityFramework binary / dSYM) --- +LINEMAP="""" +if [ -f ""${PROJECT_DIR}/BugseePendingIl2CppLinemap.txt"" ]; then + LINEMAP=$(head -n1 ""${PROJECT_DIR}/BugseePendingIl2CppLinemap.txt"") +fi +if [ -z ""${LINEMAP}"" ] || [ ! -f ""${LINEMAP}"" ]; then + LINEMAP=$(find ""${PROJECT_DIR}"" -name LineNumberMappings.json 2>/dev/null | head -n1 || true) +fi +UUID=""${BUGSEE_IL2CPP_UUIDS:-}"" +if [ -z ""${UUID}"" ]; then + UF_BIN=""${BUILT_PRODUCTS_DIR}/UnityFramework.framework/UnityFramework"" + if [ ! -f ""${UF_BIN}"" ]; then + UF_BIN=$(find ""${BUILT_PRODUCTS_DIR}"" -path '*/UnityFramework.framework/UnityFramework' 2>/dev/null | head -n1 || true) + fi + if [ -f ""${UF_BIN}"" ]; then + if command -v dwarfdump >/dev/null 2>&1; then + UUID=$(dwarfdump -u ""${UF_BIN}"" 2>/dev/null | sed -n 's/.*UUID: \([^ ]*\).*/\1/p' | head -n1 | tr '[:upper:]' '[:lower:]' || true) + fi + if [ -z ""${UUID}"" ] && command -v otool >/dev/null 2>&1; then + UUID=$(otool -l ""${UF_BIN}"" 2>/dev/null | awk '/uuid/ {print tolower($2); exit}' || true) + fi + fi +fi +if [ -n ""${LINEMAP}"" ] && [ -f ""${LINEMAP}"" ] && [ -n ""${UUID}"" ]; then + echo ""Bugsee: uploading il2cpp-linemap ${LINEMAP} uuid=${UUID}"" + upload ""${CLI}"" debug-files upload ""${LINEMAP}"" --type il2cpp-linemap --version ""${VERSION}"" --build ""${BUILD}"" --uuid ""${UUID}"" +else + echo ""Bugsee: skip il2cpp-linemap (map='${LINEMAP}' uuid='${UUID}')"" +fi +exit 0 +"; + } +} diff --git a/Runtime/WrapperPolicy/IosArchiveUploadScript.cs.meta b/Runtime/WrapperPolicy/IosArchiveUploadScript.cs.meta new file mode 100644 index 0000000..83a8e05 --- /dev/null +++ b/Runtime/WrapperPolicy/IosArchiveUploadScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ce3bbb521d3b438896bac727f14a3aa9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs b/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs index 5fbe476..84f5604 100644 --- a/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs +++ b/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs @@ -1,44 +1,156 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; using NUnit.Framework; namespace Bugsee.WrapperPolicy.Tests { public class IosSymbolUploadGateTests { - static string SymbolUploadSource() - { - var dir = TestContext.CurrentContext.TestDirectory; - while (!string.IsNullOrEmpty(dir)) - { - if (File.Exists(Path.Combine(dir, "package.json"))) - return File.ReadAllText(Path.Combine(dir, "Editor", "BugseeSymbolUpload.cs")); - dir = Directory.GetParent(dir)?.FullName; - } - - throw new InvalidOperationException("Could not locate repo root (package.json)."); - } - // A crash from a Debug (Development) build is unsymbolicated without its dSYM, so no configuration may be // skipped unless the build environment asks. [Test] public void Archive_script_uploads_every_configuration_unless_debug_is_opted_out() { - string text = SymbolUploadSource(); - Assert.That(text, Does.Not.Contain("!= \\\"Release\\\"")); + string text = IosArchiveUploadScript.Build(); + Assert.That(text, Does.Not.Contain("!= \"Release\"")); Assert.That(text, Does.Not.Contain("BUGSEE_UPLOAD_ON_DEBUG")); Assert.That(text, Does.Contain("${BUGSEE_SKIP_UPLOAD_ON_DEBUG}")); - Assert.That(text, Does.Contain("${CONFIGURATION}\\\" = \\\"Debug\\\"")); + Assert.That(text, Does.Contain("${CONFIGURATION}\" = \"Debug\"")); } // Unity's Debug configuration writes no dSYM, so an upload there would otherwise end silently with nothing sent. [Test] public void Archive_script_warns_when_the_build_has_no_dsym() { - string text = SymbolUploadSource(); + string text = IosArchiveUploadScript.Build(); Assert.That(text, Does.Contain( - "if [ ! -d \\\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\\\" ] && [ ! -d \\\"${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM\\\" ]; then")); - Assert.That(text, Does.Contain("echo \\\"warning: Bugsee: no dSYM for ${CONFIGURATION}")); + "if [ ! -d \"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\" ] && [ ! -d \"${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM\" ]; then")); + Assert.That(text, Does.Contain("echo \"warning: Bugsee: no dSYM for ${CONFIGURATION}")); + } + + [Test] + public void Archive_script_has_unix_line_endings() + { + Assert.That(IosArchiveUploadScript.Build(), Does.Not.Contain("\r")); + } + + // Xcode copies the dSYMs shipped in embedded xcframeworks (Bugsee.framework.dSYM) next to the app's. Without + // that one a crash with a Bugsee frame on the stack stays missing_sym, so every dSYM there is uploaded. + [Test] + public void Archive_script_uploads_every_dsym_in_the_dsym_folder() + { + var run = RunScript(new[] { "BugseeMiniGame.app.dSYM", "UnityFramework.framework.dSYM", "Bugsee.framework.dSYM" }); + + var dsyms = run.Calls.Where(c => c.Contains("--type dsym")).ToList(); + Assert.That(dsyms, Has.Count.EqualTo(3), run.Output); + foreach (var name in new[] { "BugseeMiniGame.app.dSYM", "UnityFramework.framework.dSYM", "Bugsee.framework.dSYM" }) + Assert.That(dsyms, Has.One.Contains("/" + name + " --type dsym --version 1.2 --build 34"), run.Output); + Assert.That(run.Output, Does.Not.Contain("warning: Bugsee: no dSYM")); + Assert.That(run.ExitCode, Is.EqualTo(0)); + } + + // Debug with DEBUG_INFORMATION_FORMAT=dwarf: only the xcframework's dSYM is there. It is still uploaded, and + // the warning about the app's missing dSYM still shows. + [Test] + public void Archive_script_uploads_the_sdk_dsym_and_warns_when_the_app_has_none() + { + var run = RunScript(new[] { "Bugsee.framework.dSYM" }); + + var dsyms = run.Calls.Where(c => c.Contains("--type dsym")).ToList(); + Assert.That(dsyms, Has.Count.EqualTo(1), run.Output); + Assert.That(dsyms[0], Does.Contain("/Bugsee.framework.dSYM --type dsym")); + Assert.That(run.Output, Does.Contain("warning: Bugsee: no dSYM for Debug")); + Assert.That(run.ExitCode, Is.EqualTo(0)); + } + + [Test] + public void Archive_script_never_fails_the_build_when_the_cli_fails() + { + var run = RunScript(new[] { "BugseeMiniGame.app.dSYM", "Bugsee.framework.dSYM" }, cliExitCode: 3); + + Assert.That(run.Calls.Count(c => c.Contains("--type dsym")), Is.EqualTo(2), run.Output); + Assert.That(run.Output, Does.Contain("Bugsee: upload command failed (archive continues)")); + Assert.That(run.ExitCode, Is.EqualTo(0)); + } + + [Test] + public void Archive_script_uploads_nothing_without_a_token() + { + var run = RunScript(new[] { "BugseeMiniGame.app.dSYM" }, token: ""); + + Assert.That(run.Calls, Is.Empty); + Assert.That(run.Output, Does.Contain("BUGSEE_APP_TOKEN unset")); + Assert.That(run.ExitCode, Is.EqualTo(0)); + } + + class ScriptRun + { + public int ExitCode; + public string Output; + public List Calls; + } + + static ScriptRun RunScript(string[] dsymBundles, int cliExitCode = 0, string token = "test-token") + { + if (!File.Exists("/bin/sh")) + Assert.Ignore("needs /bin/sh"); + + var root = Path.Combine(Path.GetTempPath(), "bugsee-upload-script-" + Guid.NewGuid().ToString("N")); + try + { + var dsymDir = Path.Combine(root, "Build", "Products", "Debug-iphoneos"); + var projectDir = Path.Combine(root, "project"); + Directory.CreateDirectory(dsymDir); + Directory.CreateDirectory(projectDir); + foreach (var bundle in dsymBundles) + Directory.CreateDirectory(Path.Combine(dsymDir, bundle, "Contents", "Resources", "DWARF")); + + var calls = Path.Combine(root, "calls.txt"); + var cli = Path.Combine(root, "fake-cli.sh"); + File.WriteAllText(cli, "#!/bin/sh\necho \"$*\" >> \"" + calls + "\"\nexit " + cliExitCode + "\n"); + File.SetUnixFileMode(cli, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var script = Path.Combine(projectDir, "upload-symbols.sh"); + File.WriteAllText(script, IosArchiveUploadScript.Build()); + + var info = new ProcessStartInfo("/bin/sh", "\"" + script + "\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + info.Environment["CONFIGURATION"] = "Debug"; + info.Environment["BUGSEE_APP_TOKEN"] = token; + info.Environment["BUGSEE_CLI_PATH"] = cli; + info.Environment["MARKETING_VERSION"] = "1.2"; + info.Environment["CURRENT_PROJECT_VERSION"] = "34"; + info.Environment["DWARF_DSYM_FOLDER_PATH"] = dsymDir; + info.Environment["DWARF_DSYM_FILE_NAME"] = "BugseeMiniGame.app.dSYM"; + info.Environment["BUILT_PRODUCTS_DIR"] = dsymDir; + info.Environment["PROJECT_DIR"] = projectDir; + info.Environment.Remove("BUGSEE_SKIP_UPLOAD_ON_DEBUG"); + info.Environment.Remove("BUGSEE_IL2CPP_UUIDS"); + + using (var process = Process.Start(info)) + { + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + Assert.That(process.WaitForExit(30000), Is.True, "upload script timed out"); + return new ScriptRun + { + ExitCode = process.ExitCode, + Output = stdout.Result + stderr.Result, + Calls = File.Exists(calls) ? File.ReadAllLines(calls).ToList() : new List(), + }; + } + } + finally + { + try { Directory.Delete(root, true); } + catch (IOException) { } + } } } } From c63d84e219b759ccdfaf476342a5dae899f88c8a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 19:39:04 +0500 Subject: [PATCH 098/157] iOS: fall back to the SDK dSYM inside the xcframework. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the layouts checked (Xcode 27.1, remote SPM: xcodebuild build Debug and Release for a device, and xcodebuild archive), Xcode's ProcessXCFramework step copies Bugsee.xcframework/ios-arm64/dSYMs/Bugsee.framework.dSYM into DWARF_DSYM_FOLDER_PATH before the target's Run Scripts, and the script uploads it from there. Should a layout lack that copy, the script now takes the dSYM from the xcframework itself, in the slice for PLATFORM_NAME (ios-arm64, or the simulator slice): first the SPM checkout under ${BUILD_DIR%Build/*}SourcePackages/artifacts, then a local package inside the exported project. It is not uploaded twice when Xcode did copy it. Tests plant the xcframework (three slices) in both layouts and check the right slice is uploaded; they fail without the fallback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../WrapperPolicy/IosArchiveUploadScript.cs | 23 +++++++- .../WrapperPolicy/IosSymbolUploadGateTests.cs | 55 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Runtime/WrapperPolicy/IosArchiveUploadScript.cs b/Runtime/WrapperPolicy/IosArchiveUploadScript.cs index db5d5ee..7fad8f5 100644 --- a/Runtime/WrapperPolicy/IosArchiveUploadScript.cs +++ b/Runtime/WrapperPolicy/IosArchiveUploadScript.cs @@ -15,7 +15,10 @@ public static class IosArchiveUploadScript // Every dSYM in DWARF_DSYM_FOLDER_PATH is uploaded, not only the app's and UnityFramework's: Xcode also copies // there the dSYMs shipped inside the xcframeworks the app embeds, Bugsee.framework.dSYM among them. Without // that one, a crash with a Bugsee frame on the stack (the SDK's own capture code, Bugsee.testCrash) stays - // "missing_sym" and those frames show as bare addresses. + // "missing_sym" and those frames show as bare addresses. (Xcode's ProcessXCFramework step copies it there + // before the target's Run Scripts, for xcodebuild build and archive alike.) Should a layout lack that copy, + // the dSYM is taken from the xcframework itself: the SPM checkout in DerivedData or a local package inside + // the exported project, in the slice for PLATFORM_NAME. // // Unity exports Debug with DEBUG_INFORMATION_FORMAT=dwarf, which writes no dSYM for the app or // UnityFramework: say so rather than finish as if the crash symbols were uploaded. @@ -46,6 +49,24 @@ exit 0 upload ""${CLI}"" debug-files upload ""${DSYM}"" --type dsym --version ""${VERSION}"" --build ""${BUILD}"" done fi +# Fallback for a layout where Xcode did not copy the SDK's dSYM next to the app's: take it from the xcframework +# itself (remote SPM checkout in DerivedData, or a local package in the exported project), slice for this platform. +if [ ! -d ""${DWARF_DSYM_FOLDER_PATH}/Bugsee.framework.dSYM"" ]; then + case ""${PLATFORM_NAME}"" in + iphonesimulator) SLICE='ios-*simulator' ;; + *) SLICE='ios-arm64' ;; + esac + SDK_DSYM="""" + for ROOT in ""${BUILD_DIR%Build/*}SourcePackages/artifacts"" ""${PROJECT_DIR}""; do + [ -n ""${ROOT}"" ] && [ -d ""${ROOT}"" ] || continue + SDK_DSYM=$(find ""${ROOT}"" -type d -path ""*/Bugsee.xcframework/${SLICE}/dSYMs/Bugsee.framework.dSYM"" 2>/dev/null | head -n1 || true) + [ -n ""${SDK_DSYM}"" ] && break + done + if [ -n ""${SDK_DSYM}"" ]; then + echo ""Bugsee: uploading SDK dSYM ${SDK_DSYM}"" + upload ""${CLI}"" debug-files upload ""${SDK_DSYM}"" --type dsym --version ""${VERSION}"" --build ""${BUILD}"" + fi +fi if [ ! -d ""${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}"" ] && [ ! -d ""${DWARF_DSYM_FOLDER_PATH}/UnityFramework.framework.dSYM"" ]; then echo ""warning: Bugsee: no dSYM for ${CONFIGURATION} in ${DWARF_DSYM_FOLDER_PATH}; set DEBUG_INFORMATION_FORMAT=dwarf-with-dsym to symbolicate its crashes."" fi diff --git a/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs b/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs index 84f5604..a19f113 100644 --- a/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs +++ b/Tests~/WrapperPolicy/IosSymbolUploadGateTests.cs @@ -93,7 +93,57 @@ class ScriptRun public List Calls; } - static ScriptRun RunScript(string[] dsymBundles, int cliExitCode = 0, string token = "test-token") + // The SPM checkout of the SDK as Xcode lays it out: every slice of the xcframework has its own dSYM. + static readonly string[] Slices = { "ios-arm64", "ios-arm64_x86_64-simulator", "tvos-arm64" }; + + static void PlantXcframework(string parent) + { + foreach (var slice in Slices) + Directory.CreateDirectory(Path.Combine(parent, "Bugsee.xcframework", slice, "dSYMs", "Bugsee.framework.dSYM", + "Contents", "Resources", "DWARF")); + } + + // Should Xcode not copy the SDK's dSYM next to the app's, it is taken from the remote SPM checkout in + // DerivedData, in the slice of the platform being built. + [TestCase("iphoneos", "ios-arm64")] + [TestCase("iphonesimulator", "ios-arm64_x86_64-simulator")] + public void Archive_script_falls_back_to_the_sdk_dsym_in_the_spm_checkout(string platform, string slice) + { + var run = RunScript(new[] { "BugseeMiniGame.app.dSYM" }, platform: platform, + plant: root => PlantXcframework(Path.Combine(root, "SourcePackages", "artifacts", "spm", "Bugsee"))); + + var dsyms = run.Calls.Where(c => c.Contains("--type dsym")).ToList(); + Assert.That(dsyms, Has.Count.EqualTo(2), run.Output); + Assert.That(dsyms, Has.One.Contains( + "/SourcePackages/artifacts/spm/Bugsee/Bugsee.xcframework/" + slice + "/dSYMs/Bugsee.framework.dSYM --type dsym"), run.Output); + } + + // A local package: the xcframework inside the exported Xcode project. + [Test] + public void Archive_script_falls_back_to_the_sdk_dsym_in_a_local_package() + { + var run = RunScript(new string[0], plant: root => PlantXcframework(Path.Combine(root, "project", "Bugsee"))); + + var dsyms = run.Calls.Where(c => c.Contains("--type dsym")).ToList(); + Assert.That(dsyms, Has.Count.EqualTo(1), run.Output); + Assert.That(dsyms[0], Does.Contain("/project/Bugsee/Bugsee.xcframework/ios-arm64/dSYMs/Bugsee.framework.dSYM --type dsym")); + } + + // The layout Xcode produces (seen on device builds and an archive): the copy next to the app's dSYM is the + // one uploaded, and the xcframework's is not sent a second time. + [Test] + public void Archive_script_uploads_the_sdk_dsym_once_when_xcode_copied_it() + { + var run = RunScript(new[] { "BugseeMiniGame.app.dSYM", "Bugsee.framework.dSYM" }, + plant: root => PlantXcframework(Path.Combine(root, "SourcePackages", "artifacts", "spm", "Bugsee"))); + + var dsyms = run.Calls.Where(c => c.Contains("Bugsee.framework.dSYM")).ToList(); + Assert.That(dsyms, Has.Count.EqualTo(1), run.Output); + Assert.That(dsyms[0], Does.Contain("/Build/Products/Debug-iphoneos/Bugsee.framework.dSYM --type dsym")); + } + + static ScriptRun RunScript(string[] dsymBundles, int cliExitCode = 0, string token = "test-token", + string platform = "iphoneos", Action plant = null) { if (!File.Exists("/bin/sh")) Assert.Ignore("needs /bin/sh"); @@ -107,6 +157,7 @@ static ScriptRun RunScript(string[] dsymBundles, int cliExitCode = 0, string tok Directory.CreateDirectory(projectDir); foreach (var bundle in dsymBundles) Directory.CreateDirectory(Path.Combine(dsymDir, bundle, "Contents", "Resources", "DWARF")); + plant?.Invoke(root); var calls = Path.Combine(root, "calls.txt"); var cli = Path.Combine(root, "fake-cli.sh"); @@ -129,6 +180,8 @@ static ScriptRun RunScript(string[] dsymBundles, int cliExitCode = 0, string tok info.Environment["DWARF_DSYM_FOLDER_PATH"] = dsymDir; info.Environment["DWARF_DSYM_FILE_NAME"] = "BugseeMiniGame.app.dSYM"; info.Environment["BUILT_PRODUCTS_DIR"] = dsymDir; + info.Environment["BUILD_DIR"] = Path.Combine(root, "Build", "Products"); + info.Environment["PLATFORM_NAME"] = platform; info.Environment["PROJECT_DIR"] = projectDir; info.Environment.Remove("BUGSEE_SKIP_UPLOAD_ON_DEBUG"); info.Environment.Remove("BUGSEE_IL2CPP_UUIDS"); From 8a751b61059a3b3e9c60c676e275dfbdbab67cd6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:34:57 +0500 Subject: [PATCH 099/157] Map iOS appearance calls onto BugseeTheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +[Bugsee getAppearance] returns a BugseeTheme, not a BGSAppearance, so the _bugsee_appearance_* functions did nothing. Map the Unity property names to BugseeTheme properties and skip (documented) the ones iOS has no equivalent for. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 4 + Plugins/iOS/BugseeUnityBridge.mm | 129 ++++++++++++++------ Runtime/Contracts/Appearance/IAppearance.cs | 9 ++ Runtime/Platform/IOS/IOSBridge.cs | 18 ++- Tests~/iOS/BridgeTests.mm | 83 +++++++++++++ 5 files changed, 200 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab75f47..46394f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - Unreleased +### Fixed + +- iOS appearance setters and getters were a no-op because `+[Bugsee getAppearance]` returns a `BugseeTheme`, not a `BGSAppearance`. They now map onto `BugseeTheme` (report/feedback colors, report placeholders). Properties with no iOS equivalent are skipped and read back null: Report `ActionBarTextColor`, `ActionBarButtonBackgroundClickedColor`, `SeverityLabelActiveColor`; Feedback `ActionBarButtonBackgroundClickedColor`, `DateTextColor`, `EmailSkipBackgroundClickedColor`, `EmailContinueClickedColor`, `InputTextHintColor`, `BottomDelimiterColor`, `LoadingBarBackgroundColor`, `LoadingTextColor`, `ErrorTextColor`, `VersionChangedBackgroundColor`, `VersionChangedTextColor`; `Notification::Title`. + ### Added - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 42ea278..63831e5 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -478,39 +478,103 @@ void _bugsee_feedback_set_greeting(const char *message) NSLog(@"[Bugsee] Feedback greeting is not in the core 7.x SPM package; install the Feedback module to enable it."); } -static id BugseeAppearanceOrNil(void) -{ - id appearance = [Bugsee getAppearance]; - if ([appearance conformsToProtocol:@protocol(BGSAppearance)]) { - return (id)appearance; +// Unity property name -> BugseeTheme property (the 7.x iOS theme API; there is no BGSAppearance object). +// Names with no row have no iOS equivalent and are skipped. +typedef struct { + const char *name; + const char *themeKey; +} BugseeThemeProperty; + +static const BugseeThemeProperty kBugseeThemeProperties[] = { + { "Report::ActionBarColor", "reportNavigationBarColor" }, + { "Report::EditTextBackgroundColor", "reportCellBackgroundColor" }, + { "Report::VersionColor", "reportVersionColor" }, + { "Report::TextColor", "reportTextColor" }, + { "Report::HintColor", "reportPlaceholderColor" }, + { "Report::BackgroundColor", "reportBackgroundColor" }, + { "Report::SummaryPlaceholder", "reportSummaryPlaceholder" }, + { "Report::DescriptionPlaceholder", "reportDescriptionPlaceholder" }, + { "Report::LabelsPlaceholder", "reportLabelsPlaceholder" }, + { "Report::EmailPlaceholder", "reportEmailPlaceholder" }, + { "Feedback::ActionBarColor", "feedbackBarsColor" }, + { "Feedback::BackgroundColor", "feedbackBackgroundColor" }, + { "Feedback::IncomingBubbleColor", "feedbackIncomingBubbleColor" }, + { "Feedback::OutgoingBubbleColor", "feedbackOutgoingBubbleColor" }, + { "Feedback::IncomingTextColor", "feedbackIncomingTextColor" }, + { "Feedback::OutgoingTextColor", "feedbackOutgoingTextColor" }, + { "Feedback::TitleTextColor", "feedbackTitleTextColor" }, + { "Feedback::EmailSkipTextColor", "feedbackEmailSkipColor" }, + { "Feedback::EmailBackgroundColor", "feedbackEmailBackgroundColor" }, + { "Feedback::EmailContinueNotActiveColor", "feedbackEmailContinueNotActiveColor" }, + { "Feedback::EmailContinueActiveColor", "feedbackEmailContinueActiveColor" }, + { "Feedback::InputTextColor", "feedbackInputTextColor" }, +}; + +static NSString *BugseeThemeKey(NSString *name) +{ + for (size_t i = 0; i < sizeof(kBugseeThemeProperties) / sizeof(kBugseeThemeProperties[0]); i++) { + if ([name isEqualToString:@(kBugseeThemeProperties[i].name)]) { + return @(kBugseeThemeProperties[i].themeKey); + } } - static BOOL logged; - if (!logged) { - NSLog(@"[Bugsee] getAppearance does not implement BGSAppearance; color/string property APIs are skipped."); - logged = YES; + static NSMutableSet *logged; + @synchronized([Bugsee class]) { + if (!logged) { + logged = [NSMutableSet set]; + } + if (name && ![logged containsObject:name]) { + [logged addObject:name]; + NSLog(@"[Bugsee] Appearance property %@ has no iOS equivalent; skipped.", name); + } } return nil; } -void _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a) +// Theme properties are nonatomic and read on the main thread: run the access there. +static void BugseeThemeSync(dispatch_block_t block) { - NSString *name = BugseeNSString(propertyName); - id appearance = BugseeAppearanceOrNil(); - if (!name || !appearance) { - return; + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_sync(dispatch_get_main_queue(), block); } - UIColor *color = [UIColor colorWithRed:r / 255.0 green:g / 255.0 blue:b / 255.0 alpha:a / 255.0]; - [appearance setColor:color forProperty:name]; } -char *_bugsee_appearance_get_color(const char *propertyName) +static int BugseeThemeSet(const char *propertyName, NSString *suffix, id value) { - NSString *name = BugseeNSString(propertyName); - id appearance = BugseeAppearanceOrNil(); - if (!name || !appearance) { - return NULL; + NSString *key = BugseeThemeKey(BugseeNSString(propertyName)); + if (!key || ![key hasSuffix:suffix]) { + return 0; + } + BugseeThemeSync(^{ + [[Bugsee getAppearance] setValue:value forKey:key]; + }); + return 1; +} + +static id BugseeThemeGet(const char *propertyName, NSString *suffix) +{ + NSString *key = BugseeThemeKey(BugseeNSString(propertyName)); + if (!key || ![key hasSuffix:suffix]) { + return nil; } - UIColor *value = [appearance colorForProperty:name]; + __block id value = nil; + BugseeThemeSync(^{ + value = [[Bugsee getAppearance] valueForKey:key]; + }); + return value; +} + +// Return 1 when the property exists on iOS and the theme was updated. +int _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a) +{ + return BugseeThemeSet(propertyName, @"Color", + [UIColor colorWithRed:r / 255.0 green:g / 255.0 blue:b / 255.0 alpha:a / 255.0]); +} + +char *_bugsee_appearance_get_color(const char *propertyName) +{ + UIColor *value = BugseeThemeGet(propertyName, @"Color"); if (!value) { return NULL; } @@ -523,24 +587,15 @@ void _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, return BugseeCopyUTF8(hex); } -void _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) +// NULL restores the SDK's default text. +int _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { - NSString *name = BugseeNSString(propertyName); - id appearance = BugseeAppearanceOrNil(); - if (!name || !appearance) { - return; - } - [appearance setString:BugseeNSString(propertyValue) ?: @"" forProperty:name]; + return BugseeThemeSet(propertyName, @"Placeholder", BugseeNSString(propertyValue)); } char *_bugsee_appearance_get_string(const char *propertyName) { - NSString *name = BugseeNSString(propertyName); - id appearance = BugseeAppearanceOrNil(); - if (!name || !appearance) { - return NULL; - } - return BugseeCopyUTF8([appearance stringForProperty:name]); + return BugseeCopyUTF8(BugseeThemeGet(propertyName, @"Placeholder")); } void _bugsee_free(char *ptr) @@ -701,9 +756,9 @@ void _bugsee_set_secure_buffer(int display, int *packed, int packedLength) void _bugsee_capture_view_hierarchy(void) {} void _bugsee_feedback_show(void) {} void _bugsee_feedback_set_greeting(const char *message) { (void)message; } -void _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a) { (void)propertyName; (void)r; (void)g; (void)b; (void)a; } +int _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a) { (void)propertyName; (void)r; (void)g; (void)b; (void)a; return 0; } char *_bugsee_appearance_get_color(const char *propertyName) { (void)propertyName; return NULL; } -void _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { (void)propertyName; (void)propertyValue; } +int _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { (void)propertyName; (void)propertyValue; return 0; } char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) diff --git a/Runtime/Contracts/Appearance/IAppearance.cs b/Runtime/Contracts/Appearance/IAppearance.cs index 8f33330..1d50d3f 100644 --- a/Runtime/Contracts/Appearance/IAppearance.cs +++ b/Runtime/Contracts/Appearance/IAppearance.cs @@ -3,6 +3,15 @@ namespace Bugsee.Contracts.Appearance { + /// + /// Appearance customization. On iOS each property maps onto the SDK's BugseeTheme; a property + /// with no iOS equivalent is ignored and reads back null. Unavailable on iOS: + /// Report ActionBarTextColor, ActionBarButtonBackgroundClickedColor, SeverityLabelActiveColor; + /// Feedback ActionBarButtonBackgroundClickedColor, DateTextColor, EmailSkipBackgroundClickedColor, + /// EmailContinueClickedColor, InputTextHintColor, BottomDelimiterColor, LoadingBarBackgroundColor, + /// LoadingTextColor, ErrorTextColor, VersionChangedBackgroundColor, VersionChangedTextColor; + /// and the Notification::Title string. + /// public interface IAppearance { IAppearance SetColor(string propertyName, Color32? color); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index ab7217f..d12be6d 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -851,9 +851,9 @@ static IDictionary WithWrapperInfo(IDictionary o [DllImport("__Internal")] static extern void _bugsee_capture_view_hierarchy(); [DllImport("__Internal")] static extern void _bugsee_feedback_show(); [DllImport("__Internal")] static extern void _bugsee_feedback_set_greeting(string message); - [DllImport("__Internal")] static extern void _bugsee_appearance_set_color(string propertyName, int r, int g, int b, int a); + [DllImport("__Internal")] static extern int _bugsee_appearance_set_color(string propertyName, int r, int g, int b, int a); [DllImport("__Internal")] static extern IntPtr _bugsee_appearance_get_color(string propertyName); - [DllImport("__Internal")] static extern void _bugsee_appearance_set_string(string propertyName, string propertyValue); + [DllImport("__Internal")] static extern int _bugsee_appearance_set_string(string propertyName, string propertyValue); [DllImport("__Internal")] static extern IntPtr _bugsee_appearance_get_string(string propertyName); [DllImport("__Internal")] static extern void _bugsee_free(IntPtr ptr); @@ -871,10 +871,12 @@ sealed class IosAppearance : IAppearance public IAppearance SetColor(string propertyName, Color32? color) { + // The native theme colors are non-nil: a null color cannot reset to the default. if (string.IsNullOrEmpty(propertyName) || !color.HasValue) return this; var c = color.Value; - _bugsee_appearance_set_color(propertyName, c.r, c.g, c.b, c.a); - _cache[propertyName] = c; + // Only properties the native theme applied are reported by ToMap. + if (_bugsee_appearance_set_color(propertyName, c.r, c.g, c.b, c.a) != 0) + _cache[propertyName] = c; return this; } @@ -894,8 +896,12 @@ public IAppearance SetColor(string propertyName, Color32? color) public IAppearance SetString(string propertyName, string value) { if (string.IsNullOrEmpty(propertyName)) return this; - _bugsee_appearance_set_string(propertyName, value ?? ""); - _cache[propertyName] = value; + // null restores the SDK's default text. + if (_bugsee_appearance_set_string(propertyName, value) != 0) + { + if (value == null) _cache.Remove(propertyName); + else _cache[propertyName] = value; + } return this; } diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index 1ae9cfc..ba1b107 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -2,6 +2,8 @@ #include #include #import +#import +#import extern "C" { void _bugsee_set_wrapper_context(const char *json); @@ -13,6 +15,10 @@ void _bugsee_set_attribute(const char *key, const char *valueJson); char *_bugsee_get_attribute(const char *key); void _bugsee_clear_attribute(const char *key); +int _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a); +char *_bugsee_appearance_get_color(const char *propertyName); +int _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue); +char *_bugsee_appearance_get_string(const char *propertyName); typedef void (*BugseeLaunchStartedFn)(int token, int success); void _bugsee_launch(const char *appToken, const char *optionsJson, int startedToken, BugseeLaunchStartedFn started); void _bugsee_relaunch(const char *optionsJson, int startedToken, BugseeLaunchStartedFn started); @@ -111,6 +117,83 @@ int main(void) free(attr); _bugsee_clear_attribute(c.name); } + // Appearance maps onto the SDK's BugseeTheme. + struct { const char *name; const char *key; } colors[] = { + { "Report::ActionBarColor", "reportNavigationBarColor" }, + { "Report::EditTextBackgroundColor", "reportCellBackgroundColor" }, + { "Report::VersionColor", "reportVersionColor" }, + { "Report::TextColor", "reportTextColor" }, + { "Report::HintColor", "reportPlaceholderColor" }, + { "Report::BackgroundColor", "reportBackgroundColor" }, + { "Feedback::ActionBarColor", "feedbackBarsColor" }, + { "Feedback::BackgroundColor", "feedbackBackgroundColor" }, + { "Feedback::IncomingBubbleColor", "feedbackIncomingBubbleColor" }, + { "Feedback::OutgoingBubbleColor", "feedbackOutgoingBubbleColor" }, + { "Feedback::IncomingTextColor", "feedbackIncomingTextColor" }, + { "Feedback::OutgoingTextColor", "feedbackOutgoingTextColor" }, + { "Feedback::TitleTextColor", "feedbackTitleTextColor" }, + { "Feedback::EmailSkipTextColor", "feedbackEmailSkipColor" }, + { "Feedback::EmailBackgroundColor", "feedbackEmailBackgroundColor" }, + { "Feedback::EmailContinueNotActiveColor", "feedbackEmailContinueNotActiveColor" }, + { "Feedback::EmailContinueActiveColor", "feedbackEmailContinueActiveColor" }, + { "Feedback::InputTextColor", "feedbackInputTextColor" }, + }; + for (auto &c : colors) { + _bugsee_appearance_set_color(c.name, 0x12, 0x34, 0x56, 0x78); + char *hex = _bugsee_appearance_get_color(c.name); + char message[128]; + snprintf(message, sizeof message, "appearance color %s round-trips", c.name); + expect(hex != NULL && same(hex, "#78123456"), message); + free(hex); + UIColor *themed = [[Bugsee getAppearance] valueForKey:@(c.key)]; + CGFloat r = 0, g = 0, b = 0, a = 0; + [themed getRed:&r green:&g blue:&b alpha:&a]; + snprintf(message, sizeof message, "appearance color %s lands on BugseeTheme.%s", c.name, c.key); + expect(themed != nil && lroundf(r * 255) == 0x12 && lroundf(b * 255) == 0x56 && lroundf(a * 255) == 0x78, message); + } + struct { const char *name; const char *key; } strings[] = { + { "Report::SummaryPlaceholder", "reportSummaryPlaceholder" }, + { "Report::DescriptionPlaceholder", "reportDescriptionPlaceholder" }, + { "Report::LabelsPlaceholder", "reportLabelsPlaceholder" }, + { "Report::EmailPlaceholder", "reportEmailPlaceholder" }, + }; + for (auto &c : strings) { + _bugsee_appearance_set_string(c.name, "h\xc3\xa9llo"); + char *text = _bugsee_appearance_get_string(c.name); + char message[128]; + snprintf(message, sizeof message, "appearance string %s round-trips", c.name); + expect(text != NULL && same(text, "h\xc3\xa9llo"), message); + free(text); + snprintf(message, sizeof message, "appearance string %s lands on BugseeTheme.%s", c.name, c.key); + expect([[[Bugsee getAppearance] valueForKey:@(c.key)] isEqualToString:@"h\u00e9llo"], message); + } + // Null restores the SDK defaults. + expect(_bugsee_appearance_set_string("Report::SummaryPlaceholder", NULL) == 1, "a null string is applied"); + expect([[Bugsee getAppearance] valueForKey:@"reportSummaryPlaceholder"] == nil, "a null string clears the theme placeholder"); + // No iOS equivalent: skipped, not faked. + const char *unavailable[] = { "Report::ActionBarTextColor", "Report::ActionBarButtonBackgroundClickedColor", + "Report::SeverityLabelActiveColor", "Feedback::DateTextColor", "Feedback::ErrorTextColor", + "Feedback::InputTextHintColor", "Notification::Title", "Nonsense" }; + for (auto name : unavailable) { + char message[128]; + snprintf(message, sizeof message, "unavailable appearance property %s is not applied", name); + expect(_bugsee_appearance_set_color(name, 1, 2, 3, 4) == 0 && _bugsee_appearance_set_string(name, "x") == 0, message); + char *color = _bugsee_appearance_get_color(name); + char *text = _bugsee_appearance_get_string(name); + snprintf(message, sizeof message, "unavailable appearance property %s reads nothing", name); + expect(color == NULL && text == NULL, message); + free(color); + free(text); + } + // A color name is not a string property and vice versa. + _bugsee_appearance_set_color("Report::TextColor", 1, 2, 3, 4); + _bugsee_appearance_set_string("Report::SummaryPlaceholder", "x"); + char *kindText = _bugsee_appearance_get_string("Report::TextColor"); + char *kindColor = _bugsee_appearance_get_color("Report::SummaryPlaceholder"); + expect(kindText == NULL, "a color property has no string value"); + expect(kindColor == NULL, "a string property has no color value"); + free(kindText); + free(kindColor); // Lifecycle against the real SDK: answers come back with the caller's token. _bugsee_launch("00000000-0000-0000-0000-000000000000", "{\"max_recording_time\":60}", 41, OnStarted); expect(waitFor(^{ return gStartedCount == 1 ? 1 : 0; }, 5) && gStartedToken == 41, From f38065c2aa38bce2c07aa53d9b6edc91e763a422 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:34:01 +0500 Subject: [PATCH 100/157] Make update-native-sdks.sh rewrite every pin and add a drift test for them. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins in docs, CHANGELOG, Tests~/iOS/run-tests.sh, the Android test wrapper and GradlePluginVersion are listed in Tools~/scripts/native-pins.txt. The script rewrites them from versions.env and WrapperIdentityDriftTests fails on any that disagree. The run-tests.sh CHECKSUM is not derivable; the script warns when its zip changes. Fixes #43 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../WrapperIdentityDriftTests.cs | 87 ++++++++++++++++++- Tools~/scripts/native-pins.txt | 37 ++++++++ Tools~/scripts/update-native-sdks.sh | 64 ++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 Tools~/scripts/native-pins.txt diff --git a/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs b/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs index dcb11d3..86910dc 100644 --- a/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs +++ b/Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs @@ -69,7 +69,92 @@ public void Native_sdk_pins_are_the_same_everywhere() var ios = Capture("Tools~/versions.env", "IOS_SDK_VERSION=\"([^\"]+)\""); Assert.That(Capture("Runtime/BugseePackageVersion.cs", "IosSdkVersion = \"([^\"]+)\""), Is.EqualTo(ios)); - Assert.That(Capture("Tests~/iOS/run-tests.sh", "Bugsee-([0-9][^.]*\\.[0-9.]+(?:-[a-z0-9]+)?)\\.zip\""), Is.EqualTo(ios)); + // The zip version in Tests~/iOS/run-tests.sh is checked by the listed pins below, which read dotted prereleases. + } + + // update-native-sdks.sh rewrites exactly these places from versions.env. Hand-edited or forgotten ones fail here. + static System.Collections.Generic.List PinEntries() + { + var entries = new System.Collections.Generic.List(); + foreach (var raw in File.ReadAllLines(RepoFile("Tools~/scripts/native-pins.txt"))) + { + var line = raw.TrimEnd('\r'); + if (line.Trim().Length == 0 || line.StartsWith("#")) continue; + entries.Add(line.Split(new[] { '|' }, 3)); + } + return entries; + } + + // CHANGELOG: only the first (current) version section; released sections keep their history. + static void PinRegion(string file, string text, out int start, out int length) + { + start = 0; + length = text.Length; + if (file != "CHANGELOG.md") return; + var first = text.IndexOf("\n## [", System.StringComparison.Ordinal); + if (first < 0) return; + var next = text.IndexOf("\n## [", first + 1, System.StringComparison.Ordinal); + start = first; + length = (next < 0 ? text.Length : next) - first; + } + + [Test] + public void Listed_pins_in_docs_tests_and_constants_match_versions_env() + { + var want = new System.Collections.Generic.Dictionary + { + ["android"] = Capture("Tools~/versions.env", "ANDROID_SDK_VERSION=\"([^\"]+)\""), + ["ios"] = Capture("Tools~/versions.env", "IOS_SDK_VERSION=\"([^\"]+)\""), + ["gradleplugin"] = Capture("Tools~/versions.env", "ANDROID_GRADLE_PLUGIN_VERSION=\"([^\"]+)\""), + }; + var drift = new System.Collections.Generic.List(); + var entries = PinEntries(); + Assert.That(entries, Is.Not.Empty); + foreach (var parts in entries) + { + Assert.That(want.ContainsKey(parts[0]), Is.True, "unknown pin kind in: " + string.Join("|", parts)); + var text = File.ReadAllText(RepoFile(parts[1])); + PinRegion(parts[1], text, out var start, out var length); + var matches = Regex.Matches(text.Substring(start, length), parts[2]); + // A new release heading leaves the CHANGELOG's first section without pins: nothing to check there. + if (parts[1] != "CHANGELOG.md") + Assert.That(matches.Count, Is.GreaterThan(0), parts[1] + " has no match for " + parts[2]); + foreach (Match m in matches) + if (m.Groups[1].Value != want[parts[0]]) + drift.Add(parts[1] + ": " + m.Groups[1].Value + ", versions.env says " + want[parts[0]]); + } + Assert.That(drift, Is.Empty, "run Tools~/scripts/update-native-sdks.sh"); + } + + [Test] + public void Changelog_pins_read_only_the_first_section() + { + const string Log = "# Changelog\n\n## [Unreleased]\n\n- nothing pinned yet\n\n## [0.1.0]\n\n- `com.bugsee:bugsee-android:7.0.0`\n"; + PinRegion("CHANGELOG.md", Log, out var start, out var length); + var region = Log.Substring(start, length); + Assert.That(region, Does.Contain("Unreleased")); + Assert.That(region, Does.Not.Contain("7.0.0"), "released sections keep their history"); + PinRegion("README.md", Log, out start, out length); + Assert.That(length, Is.EqualTo(Log.Length), "only the CHANGELOG is scoped"); + } + + // A dotted prerelease must be read whole. If the pattern stopped at the first dot, rewriting 7.5.0-rc.1 + // would leave ".1" behind and the script would append another one on every run. + [Test] + public void Pin_patterns_read_a_dotted_prerelease_whole() + { + const string Dotted = "9.9.9-rc.1"; + foreach (var parts in PinEntries()) + { + var text = File.ReadAllText(RepoFile(parts[1])); + PinRegion(parts[1], text, out var start, out var length); + var region = text.Substring(start, length); + var rewritten = Regex.Replace(region, parts[2], m => + m.Value.Substring(0, m.Groups[1].Index - m.Index) + Dotted + + m.Value.Substring(m.Groups[1].Index - m.Index + m.Groups[1].Length)); + foreach (Match m in Regex.Matches(rewritten, parts[2])) + Assert.That(m.Groups[1].Value, Is.EqualTo(Dotted), parts[1] + " " + parts[2]); + } } } } diff --git a/Tools~/scripts/native-pins.txt b/Tools~/scripts/native-pins.txt new file mode 100644 index 0000000..6fc62e1 --- /dev/null +++ b/Tools~/scripts/native-pins.txt @@ -0,0 +1,37 @@ +# Every place outside the generated files that spells out a pinned version from Tools~/versions.env. +# One per line: ||. Group 1 of the regex is the version. kind is ios, android or gradleplugin. +# Read by update-native-sdks.sh (rewrites group 1) and Tests~/WrapperPolicy/WrapperIdentityDriftTests.cs (fails on drift). +# Keep regexes to syntax Python and .NET share. A pattern that matches nothing fails both. +# In CHANGELOG.md only the first (current) version section is read and rewritten; released sections keep their history. +# Not here: Tests~/iOS/run-tests.sh CHECKSUM (a hash of the zip, not derivable; the script warns when the zip version moves). +android|CHANGELOG.md|bugsee-android:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|CHANGELOG.md|bugsee-android-ndk:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|README.md|bugsee-android:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|README.md|bugsee-android-ndk:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|DESIGN.md|bugsee-android:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|DESIGN.md|bugsee-android-ndk:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|Documentation~/index.md|bugsee-android:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|Documentation~/index.md|bugsee-android-ndk:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|Samples~/BugseeMiniGame/README.md|bugsee-android:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|Samples~/BugseeMiniGame/README.md|bugsee-android-ndk:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|CHANGELOG.md|bugsee-android-ndk` ([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|README.md|\*\*([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)\*\*, added to +android|DESIGN.md|declares Maven ([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +android|Tests~/Android/wrapper/build.gradle|bugsee-android:([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +ios|CHANGELOG.md|iOS `bugsee/spm` ([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +ios|CHANGELOG.md|\(https://github.com/bugsee/spm\) `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +ios|README.md|\(https://github.com/bugsee/spm\) `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +ios|DESIGN.md|\(https://github.com/bugsee/spm\) `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +ios|Documentation~/index.md|\(https://github.com/bugsee/spm\) `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +ios|DESIGN.md|github.com/bugsee/spm` `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +ios|Samples~/BugseeMiniGame/README.md|github.com/bugsee/spm` `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +ios|Documentation~/index.md|Bugsee ([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)\) +ios|Native~/ios/Bugsee/Package.swift|bugsee/spm @ ([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +ios|Tools~/scripts/validate-field-symbols.sh|IOS_SDK_VERSION:-([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?) +ios|Tests~/iOS/run-tests.sh|Bugsee-([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)\.zip +gradleplugin|CHANGELOG.md|Gradle plugin `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +gradleplugin|README.md|Gradle plugin `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +gradleplugin|DESIGN.md|Gradle plugin `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +gradleplugin|Documentation~/index.md|Gradle plugin `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +gradleplugin|Samples~/BugseeMiniGame/README.md|Gradle plugin `([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)` +gradleplugin|Editor/BugseeAndroidGradleSetup.cs|GradlePluginVersion = "([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]*[0-9A-Za-z])?)" diff --git a/Tools~/scripts/update-native-sdks.sh b/Tools~/scripts/update-native-sdks.sh index 749e42d..f502703 100755 --- a/Tools~/scripts/update-native-sdks.sh +++ b/Tools~/scripts/update-native-sdks.sh @@ -3,6 +3,7 @@ # # - Regenerates Runtime/BugseePackageVersion.cs (also the Android Maven coordinates that # Editor/BugseeAndroidMavenDependencies.cs declares in the generated Gradle project) +# - Rewrites the pins listed in Tools~/scripts/native-pins.txt (docs, CHANGELOG, Tests~/iOS/run-tests.sh, ...) # - Verifies the iOS SPM tag on github.com/bugsee/spm (no local xcframework copy) set -euo pipefail @@ -18,6 +19,69 @@ fi # shellcheck disable=SC1090 source "${VERSIONS_FILE}" +# Pins spelled out in docs, test scaffolding and editor constants (list: native-pins.txt). +# The drift test in Tests~/WrapperPolicy fails on any of them that disagrees with versions.env. +# This runs first and checks every entry before anything is written (here or by the generators below), so a +# bad entry never leaves a half-applied bump. +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to rewrite the pins listed in Tools~/scripts/native-pins.txt" >&2 + exit 1 +fi +python3 - "${ROOT_DIR}" "${ANDROID_SDK_VERSION}" "${IOS_SDK_VERSION}" "${ANDROID_GRADLE_PLUGIN_VERSION}" <<'PY' +import os, re, sys +root, android, ios, plugin = sys.argv[1:5] +want = {"android": android, "ios": ios, "gradleplugin": plugin} +with open(os.path.join(root, "Tools~", "scripts", "native-pins.txt"), encoding="utf-8") as f: + entries = [l.rstrip("\r\n").split("|", 2) for l in f if l.strip() and not l.startswith("#")] + +def region(rel, text): + # CHANGELOG: only the first (current) version section; released sections keep their history. + if rel != "CHANGELOG.md": + return 0, len(text) + start = text.find("\n## [") + if start < 0: + return 0, len(text) + end = text.find("\n## [", start + 1) + return start, len(text) if end < 0 else end + +texts, errors = {}, [] +for kind, rel, pattern in entries: + if kind not in want: + errors.append(f"unknown kind {kind!r} in native-pins.txt") + continue + if rel not in texts: + with open(os.path.join(root, rel), encoding="utf-8", newline="") as f: + texts[rel] = f.read() + lo, hi = region(rel, texts[rel]) + # A new release heading leaves the CHANGELOG's first section without the pins: nothing to rewrite there. + if rel != "CHANGELOG.md" and not re.search(pattern, texts[rel][lo:hi]): + errors.append(f"native-pins.txt: {rel} has no match for {pattern}") +if errors: + sys.exit("\n".join(errors)) + +for kind, rel, pattern in entries: + text = texts[rel] + lo, hi = region(rel, text) + out, last = [], 0 + for m in re.finditer(pattern, text[lo:hi]): + out += [text[last:lo + m.start(1)], want[kind]] + last = lo + m.end(1) + out.append(text[last:]) + texts[rel] = "".join(out) + +for rel, text in texts.items(): + path = os.path.join(root, rel) + with open(path, encoding="utf-8", newline="") as f: + if f.read() == text: + continue + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(text) + print(f"Updated {rel}") + if rel == "Tests~/iOS/run-tests.sh": + print("WARNING: Tests~/iOS/run-tests.sh now downloads a different zip. Update its CHECKSUM (sha256 of the zip) by hand.", file=sys.stderr) +PY + + RUNTIME_VERSION_CS="${ROOT_DIR}/Runtime/BugseePackageVersion.cs" echo "==> Package ${BUGSEE_PACKAGE_VERSION}" From fd99715562f9f1e813bda2c5b0fe13f32a093113 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:54:01 +0500 Subject: [PATCH 101/157] Release Android proxy JNI arguments deterministically after the deferred callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AndroidJavaObject arguments of EventFilterProxy.filter and the report-handler completion are backed by JNI global references (UnityEngine.AndroidJNIModule GlobalJavaObjectRef), so dereferencing them in the main-thread drain is not the local-ref misuse described in #1. Dispose them once the callback has run so they do not wait for the finalizer. Filter event wrappers are invalidated at that point and throw ObjectDisposedException if a filter stored the event and used it later. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../Platform/Android/AndroidExchangeEvents.cs | 51 ++++++++++++++---- .../Android/Proxies/CallbackProxies.cs | 52 +++++++++++++++---- Tests~/WrapperPolicy/FilterCompletionTests.cs | 47 +++++++++++++++++ 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/Runtime/Platform/Android/AndroidExchangeEvents.cs b/Runtime/Platform/Android/AndroidExchangeEvents.cs index b2a04c1..32d1059 100644 --- a/Runtime/Platform/Android/AndroidExchangeEvents.cs +++ b/Runtime/Platform/Android/AndroidExchangeEvents.cs @@ -10,16 +10,39 @@ namespace Bugsee.Platform.Android /// /// Thin wrappers over Java exchange events. Mutate in place so filter /// callbacks write through to the native instance the SDK owns. + /// Event objects handed to a filter are valid only inside the filter callback: once it + /// returns the proxy releases the native reference, and later access throws + /// . /// - sealed class AndroidNetworkEvent : INetworkEvent + interface IReleasableNativeEvent { - readonly AndroidJavaObject _native; + void ReleaseNative(); + } + + static class NativeEventGuard + { + internal static AndroidJavaObject Live(AndroidJavaObject native, bool released, string type) + { + if (released || native == null) + throw new ObjectDisposedException(type, + "Bugsee event objects are valid only inside the filter callback; do not store them."); + return native; + } + } + sealed class AndroidNetworkEvent : INetworkEvent, IReleasableNativeEvent + { + readonly AndroidJavaObject _nativeRaw; + bool _released; + + AndroidJavaObject _native => NativeEventGuard.Live(_nativeRaw, _released, nameof(AndroidNetworkEvent)); + + public void ReleaseNative() => _released = true; internal AndroidJavaObject Native => _native; public AndroidNetworkEvent(AndroidJavaObject native) { - _native = native; + _nativeRaw = native; } public string Id => _native.Call("getId"); @@ -97,13 +120,18 @@ public NetworkEventStage Stage } } - sealed class AndroidLogEvent : ILogEvent + sealed class AndroidLogEvent : ILogEvent, IReleasableNativeEvent { - readonly AndroidJavaObject _native; + readonly AndroidJavaObject _nativeRaw; + bool _released; + + AndroidJavaObject _native => NativeEventGuard.Live(_nativeRaw, _released, nameof(AndroidLogEvent)); + + public void ReleaseNative() => _released = true; public AndroidLogEvent(AndroidJavaObject native) { - _native = native; + _nativeRaw = native; } public string Message @@ -146,13 +174,18 @@ public LogLevel Level } } - sealed class AndroidBreadcrumb : IBreadcrumb + sealed class AndroidBreadcrumb : IBreadcrumb, IReleasableNativeEvent { - readonly AndroidJavaObject _native; + readonly AndroidJavaObject _nativeRaw; + bool _released; + + AndroidJavaObject _native => NativeEventGuard.Live(_nativeRaw, _released, nameof(AndroidBreadcrumb)); + + public void ReleaseNative() => _released = true; public AndroidBreadcrumb(AndroidJavaObject native) { - _native = native; + _nativeRaw = native; } public long Timestamp diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index edd72a7..d7d4bb6 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -30,16 +30,21 @@ public void filter(AndroidJavaObject data, AndroidJavaObject callback) var filter = _filter; if (filter == null) { - callback?.Call("run", data); + try { callback?.Call("run", data); } + finally { ReleaseArgs(data, callback); } return; } // Filters may touch managed state — run on main thread, then complete. + // The AndroidJavaObject arguments are backed by JNI global references, not the proxy + // call's local frame, so they stay valid across the hop; release them deterministically + // once the callback has run rather than waiting for the finalizer. MainThreadDispatcher.Run(() => { + TNative managed = null; try { - var managed = _wrap(data); + managed = _wrap(data); var result = filter(managed); callback?.Call("run", result == null ? (AndroidJavaObject)null : data); } @@ -48,8 +53,21 @@ public void filter(AndroidJavaObject data, AndroidJavaObject callback) Debug.Log("filter-failed " + ex.GetType().Name); callback?.Call("run", (AndroidJavaObject)null); } + finally + { + // The wrapper must not outlive the native reference: a stored event fails with + // ObjectDisposedException instead of touching a released object. + (managed as IReleasableNativeEvent)?.ReleaseNative(); + ReleaseArgs(data, callback); + } }); } + + static void ReleaseArgs(AndroidJavaObject data, AndroidJavaObject callback) + { + try { data?.Dispose(); } catch (Exception) { } + try { callback?.Dispose(); } catch (Exception) { } + } } sealed class ReportHandlerProxy : AndroidJavaProxy @@ -63,6 +81,12 @@ public ReportHandlerProxy() public void SetHandler(IReportHandler handler) => _handler = handler; + static void ReleaseArgs(AndroidJavaObject report, AndroidJavaObject completion) + { + try { report?.Dispose(); } catch (Exception) { } + try { completion?.Dispose(); } catch (Exception) { } + } + public void onBeforeReportCreated(AndroidJavaObject report, bool isTerminating, AndroidJavaObject completion) { Invoke(report, isTerminating, completion, before: true); @@ -78,13 +102,15 @@ void Invoke(AndroidJavaObject report, bool isTerminating, AndroidJavaObject comp var handler = _handler; if (handler == null) { - completion?.Call("run"); + try { completion?.Call("run"); } + finally { ReleaseArgs(report, completion); } return; } if (isTerminating) { - completion?.Call("run"); + try { completion?.Call("run"); } + finally { ReleaseArgs(report, completion); } return; } @@ -95,7 +121,8 @@ void Invoke(AndroidJavaObject report, bool isTerminating, AndroidJavaObject comp { if (finished) return; finished = true; - completion?.Call("run"); + try { completion?.Call("run"); } + finally { try { completion?.Dispose(); } catch (Exception) { } } }; try @@ -129,9 +156,14 @@ public void onEvent(string eventType, object data) try { var javaData = data as AndroidJavaObject; - // Unbox on the calling (often background) thread so we don't retain - // a Java local ref across the main-thread hop. - managedData = javaData != null ? AndroidJavaConverters.UnboxLifecycleData(javaData) : data; + // Unbox on the calling (often background) thread so only managed values cross the + // main-thread hop, then release the argument's global reference. + if (javaData != null) + { + try { managedData = AndroidJavaConverters.UnboxLifecycleData(javaData); } + finally { javaData.Dispose(); } + } + else managedData = data; } catch (Exception ex) { Debug.LogException(ex); } @@ -184,7 +216,8 @@ public void run(object value) if (value is AndroidJavaObject javaBoolean) { - _onResult?.Invoke(javaBoolean.Call("booleanValue")); + try { _onResult?.Invoke(javaBoolean.Call("booleanValue")); } + finally { javaBoolean.Dispose(); } return; } @@ -236,6 +269,7 @@ public void onCreated(AndroidJavaObject report) finally { _snapshot?.ReleaseSnapshotFiles(); + try { report?.Dispose(); } catch (Exception) { } } } } diff --git a/Tests~/WrapperPolicy/FilterCompletionTests.cs b/Tests~/WrapperPolicy/FilterCompletionTests.cs index d874f83..01ba752 100644 --- a/Tests~/WrapperPolicy/FilterCompletionTests.cs +++ b/Tests~/WrapperPolicy/FilterCompletionTests.cs @@ -178,6 +178,53 @@ public void Ios_native_per_kind_filter_flags() "decisionBlock(crumb)"); } + static string StripComments(string source) + { + source = System.Text.RegularExpressions.Regex.Replace(source, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline); + return System.Text.RegularExpressions.Regex.Replace(source, @"//[^\n]*", ""); + } + + [Test] + public void Android_proxies_release_java_args_in_finally() + { + string proxies = StripComments(File.ReadAllText(RepoFile("Runtime/Platform/Android/Proxies/CallbackProxies.cs"))); + string filterProxy = ExtractMethodBody(proxies, "public void filter(AndroidJavaObject data, AndroidJavaObject callback)"); + + // no-filter path: release sits in a finally after the callback. + string noFilter = ExtractMethodBody(filterProxy, "if (filter == null)"); + Assert.That(noFilter, Does.Contain("finally")); + Assert.That(noFilter.IndexOf("ReleaseArgs(data, callback)", StringComparison.Ordinal), + Is.GreaterThan(noFilter.IndexOf("finally", StringComparison.Ordinal))); + + // deferred path: wrapper invalidated and args released inside a finally. + string deferred = ExtractMethodBody(filterProxy, "MainThreadDispatcher.Run(() =>"); + string deferredFinally = ExtractMethodBody(deferred, "finally"); + Assert.That(deferredFinally, Does.Contain("ReleaseNative()")); + Assert.That(deferredFinally, Does.Contain("ReleaseArgs(data, callback)")); + + string release = ExtractMethodBody(proxies, "static void ReleaseArgs(AndroidJavaObject data, AndroidJavaObject callback)"); + Assert.That(release, Does.Contain("data?.Dispose()")); + Assert.That(release, Does.Contain("callback?.Dispose()")); + + // report handler: no-handler/terminating path and done() release completion in a finally. + string invoke = ExtractMethodBody(proxies, "void Invoke(AndroidJavaObject report, bool isTerminating"); + foreach (var branch in new[] { "if (handler == null)", "if (isTerminating)" }) + Assert.That(ExtractMethodBody(ExtractMethodBody(invoke, branch), "finally"), Does.Contain("ReleaseArgs(report, completion)")); + Assert.That(ExtractMethodBody(invoke, "Action done = () =>"), Does.Contain("completion?.Dispose()")); + } + + [Test] + public void Android_event_wrappers_fail_clearly_after_release() + { + string events = StripComments(File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidExchangeEvents.cs"))); + Assert.That(events, Does.Contain("throw new ObjectDisposedException(")); + foreach (var cls in new[] { "AndroidNetworkEvent", "AndroidLogEvent", "AndroidBreadcrumb" }) + { + Assert.That(events, Does.Contain("sealed class " + cls + " :")); + Assert.That(events, Does.Contain("NativeEventGuard.Live(_nativeRaw, _released, nameof(" + cls + "))")); + } + } + [Test] public void Android_filter_proxy_drops_on_throw() { From 9a752748764be6d77218a0fdf91109076fc68212 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:35:12 +0500 Subject: [PATCH 102/157] Add a CI job that compiles Runtime/ and Editor/ against Unity references. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-checks the package with the .NET SDK for seven Unity define sets (Android/iOS/standalone players, with and without IL2CPP, and Editor with each active target). Runtime and Editor build as two assemblies mirroring the asmdefs, so internal Runtime members are invisible to Editor. Uses the community UnityEngine.Modules 2021.3.33 and UnityTechnologies.UnityEditor 2020.2.2.1 NuGet repackagings, pinned by version and lock files, so no Unity license is needed. Editor APIs newer than 2020.2 are source stubs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/ci.yml | 17 +++++++ .gitignore | 3 ++ .../CompileCheck/Editor/Bugsee.Editor.csproj | 27 +++++++++++ .../CompileCheck/Editor/UnityEditorStubs.cs | 41 +++++++++++++++++ .../Editor/UnityEditorXcodeStubs.cs | 25 +++++++++++ Tools~/CompileCheck/Editor/packages.lock.json | 22 +++++++++ .../Runtime/Bugsee.Runtime.csproj | 31 +++++++++++++ .../CompileCheck/Runtime/packages.lock.json | 13 ++++++ Tools~/scripts/compile-check.sh | 45 +++++++++++++++++++ 9 files changed, 224 insertions(+) create mode 100644 Tools~/CompileCheck/Editor/Bugsee.Editor.csproj create mode 100644 Tools~/CompileCheck/Editor/UnityEditorStubs.cs create mode 100644 Tools~/CompileCheck/Editor/UnityEditorXcodeStubs.cs create mode 100644 Tools~/CompileCheck/Editor/packages.lock.json create mode 100644 Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj create mode 100644 Tools~/CompileCheck/Runtime/packages.lock.json create mode 100644 Tools~/scripts/compile-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4225d9b..f6afd2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,23 @@ jobs: - name: Test run: dotnet test Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj --nologo --configuration Release + unity-compile: + name: Unity C# compile check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Compile Runtime and Editor per Unity define set + run: sh Tools~/scripts/compile-check.sh + android: name: Android wrapper tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 57a6eaa..d2dd47b 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,9 @@ Tests~/**/.build/ Tests~/**/bin/ Tests~/**/obj/ Tests~/Android/local.properties +!Tools~/CompileCheck/**/*.csproj +Tools~/CompileCheck/**/bin/ +Tools~/CompileCheck/**/obj/ # Packaging *.unitypackage diff --git a/Tools~/CompileCheck/Editor/Bugsee.Editor.csproj b/Tools~/CompileCheck/Editor/Bugsee.Editor.csproj new file mode 100644 index 0000000..20ebea7 --- /dev/null +++ b/Tools~/CompileCheck/Editor/Bugsee.Editor.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.1 + Bugsee.Editor + 9.0 + disable + false + false + false + disable + true + true + $(NoWarn);CS1591;CS0618;CS0649;CS0169;CS0414;CS0436;NU1701;NETSDK1138 + + + + + + + + + + + + + diff --git a/Tools~/CompileCheck/Editor/UnityEditorStubs.cs b/Tools~/CompileCheck/Editor/UnityEditorStubs.cs new file mode 100644 index 0000000..07b5f4b --- /dev/null +++ b/Tools~/CompileCheck/Editor/UnityEditorStubs.cs @@ -0,0 +1,41 @@ +// The only UnityEditor reference assembly on nuget.org is Unity 2020.2, which lacks the 2021+ Editor +// APIs below. These source types shadow the same-named types in that assembly (source wins; CS0436 +// is suppressed) with just the members Editor/ uses. Extend when Editor/ uses more of them. +// Stubs cannot detect API gaps against Unity 2021.3 itself. +namespace UnityEditor.Build +{ + public readonly struct NamedBuildTarget + { + public static NamedBuildTarget Android => default; + } +} + +namespace UnityEditor.PackageManager +{ + public class PackageInfo + { + public string name; + public string resolvedPath; + public static PackageInfo FindForAssembly(System.Reflection.Assembly assembly) => null; + public static PackageInfo[] GetAllRegisteredPackages() => null; + } +} + +namespace UnityEditor +{ + public static class PlayerSettings + { + public static string bundleVersion; + public static ScriptingImplementation GetScriptingBackend(UnityEditor.Build.NamedBuildTarget buildTarget) => default; + + public static class Android + { + public static int bundleVersionCode; + } + + public static class iOS + { + public static string buildNumber; + } + } +} diff --git a/Tools~/CompileCheck/Editor/UnityEditorXcodeStubs.cs b/Tools~/CompileCheck/Editor/UnityEditorXcodeStubs.cs new file mode 100644 index 0000000..000cf76 --- /dev/null +++ b/Tools~/CompileCheck/Editor/UnityEditorXcodeStubs.cs @@ -0,0 +1,25 @@ +// Stub of the subset of UnityEditor.iOS.Xcode (UnityEditor.iOS.Extensions.Xcode.dll, shipped only +// inside the Editor install) that Editor/ uses. Signatures mirror Unity 2021.3. Extend when Editor/ +// starts using more of the API. +namespace UnityEditor.iOS.Xcode +{ + public class PBXProject + { + public static string GetPBXProjectPath(string buildPath) => null; + public void ReadFromFile(string path) { } + public void ReadFromString(string src) { } + public void WriteToFile(string path) { } + public string WriteToString() => null; + public string GetUnityMainTargetGuid() => null; + public string GetUnityFrameworkTargetGuid() => null; + public string AddRemotePackageReferenceAtVersion(string repositoryURL, string version) => null; + public void AddRemotePackageFrameworkToProject(string targetGuid, string productName, string packageGuid, bool weak) { } + public System.Collections.Generic.IEnumerable BuildConfigNames() => null; + public string BuildConfigByName(string targetGuid, string name) => null; + public void SetBuildPropertyForConfig(string configGuid, string name, string value) { } + public string GetBuildPropertyForConfig(string configGuid, string name) => null; + public string GetBuildPropertyForAnyConfig(string targetGuid, string name) => null; + public void SetBuildProperty(string targetGuid, string name, string value) { } + public void AddShellScriptBuildPhase(string targetGuid, string name, string shellPath, string shellScript) { } + } +} diff --git a/Tools~/CompileCheck/Editor/packages.lock.json b/Tools~/CompileCheck/Editor/packages.lock.json new file mode 100644 index 0000000..31cdb8b --- /dev/null +++ b/Tools~/CompileCheck/Editor/packages.lock.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "UnityEngine.Modules": { + "type": "Direct", + "requested": "[2021.3.33, )", + "resolved": "2021.3.33", + "contentHash": "7NtoEnLcehfJIW+DL9awtXAHj3SJcQBWOTPePwA6LmeWVz9YbHoSzs2uEOCt5meWokUqCWMVmqBT1Heu5ouWDw==" + }, + "UnityTechnologies.UnityEditor": { + "type": "Direct", + "requested": "[2020.2.2.1, )", + "resolved": "2020.2.2.1", + "contentHash": "P85mewxTieviNJaILRvEOysK+99ZuDMPGXtObnllf1/KdkqwF1DoZH0sdpYFz39Vc3DQX1ZzDciHCzqVtoYHzA==" + }, + "bugsee.runtime": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj b/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj new file mode 100644 index 0000000..6bb26d3 --- /dev/null +++ b/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.1 + Bugsee.Runtime + 9.0 + disable + false + false + false + disable + true + true + $(NoWarn);CS1591;CS0618;CS0649;CS0169;CS0414;CS0436;NU1701;NETSDK1138 + + + + + + + + + diff --git a/Tools~/CompileCheck/Runtime/packages.lock.json b/Tools~/CompileCheck/Runtime/packages.lock.json new file mode 100644 index 0000000..83ea554 --- /dev/null +++ b/Tools~/CompileCheck/Runtime/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "UnityEngine.Modules": { + "type": "Direct", + "requested": "[2021.3.33, )", + "resolved": "2021.3.33", + "contentHash": "7NtoEnLcehfJIW+DL9awtXAHj3SJcQBWOTPePwA6LmeWVz9YbHoSzs2uEOCt5meWokUqCWMVmqBT1Heu5ouWDw==" + } + } + } +} \ No newline at end of file diff --git a/Tools~/scripts/compile-check.sh b/Tools~/scripts/compile-check.sh new file mode 100644 index 0000000..d87f2b1 --- /dev/null +++ b/Tools~/scripts/compile-check.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Type-checks Runtime/ and Editor/ for each Unity define set the package is built under, as two +# assemblies like the asmdefs (Editor references Runtime). References are community repackagings of +# Unity's assemblies on NuGet, pinned by version + lock files in Tools~/CompileCheck. No Unity license. +# Limits: the Editor reference is Unity 2020.2 plus small source stubs (UnityEditorStubs.cs, +# UnityEditorXcodeStubs.cs) for newer/missing APIs. Stubs cannot detect API gaps vs Unity 2021.3 itself. +# Usage: sh Tools~/scripts/compile-check.sh (needs the .NET SDK; DOTNET_ROLL_FORWARD=Major is fine) +set -u + +root="$(cd "$(dirname "$0")/../.." && pwd)" +dir="$root/Tools~/CompileCheck" + +# name|player-only (true = Runtime assembly only, no UnityEditor)|defines +configs=' +android-player|true|UNITY_ANDROID +android-player-il2cpp|true|UNITY_ANDROID;ENABLE_IL2CPP +ios-player-il2cpp|true|UNITY_IOS;ENABLE_IL2CPP;UNITY_2023_1_OR_NEWER +standalone-player|true|UNITY_STANDALONE_OSX +editor-android|false|UNITY_EDITOR;UNITY_ANDROID +editor-ios|false|UNITY_EDITOR;UNITY_IOS +editor-standalone-il2cpp|false|UNITY_EDITOR;UNITY_STANDALONE_OSX;ENABLE_IL2CPP +' + +for p in Runtime Editor; do + dotnet restore "$dir/$p/Bugsee.$p.csproj" --locked-mode --nologo -v m || { echo "restore failed for Bugsee.$p.csproj"; exit 2; } +done + +fail=0 +for cfg in $configs; do + name="${cfg%%|*}"; rest="${cfg#*|}"; player="${rest%%|*}"; defs="${rest#*|}" + if [ "$player" = true ]; then proj="$dir/Runtime/Bugsee.Runtime.csproj"; else proj="$dir/Editor/Bugsee.Editor.csproj"; fi + out="$(dotnet build "$proj" --no-restore --no-incremental --nologo -v q \ + "-p:DefineConstants=$(printf '%s' "$defs" | sed 's/;/%3B/g')" 2>&1)" + status=$? + errors="$(printf '%s\n' "$out" | grep -E ': error ' | sed 's/ \[[^]]*\]$//' | sort -u)" + if [ -z "$errors" ] && [ $status -ne 0 ]; then + errors="build failed without a compiler diagnostic: $(printf '%s\n' "$out" | tail -n 5)" + fi + if [ -n "$errors" ]; then + echo "FAIL $name ($defs)"; printf '%s\n' "$errors" | sed 's/^/ /'; fail=1 + else + echo "ok $name ($defs)" + fi +done +exit $fail From 7046512b1010bf6f5420e9f61d0870ecd3917c77 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:37:03 +0500 Subject: [PATCH 103/157] Run iOS DeleteCollectedDataOnDevice in request order instead of skipping it after a later Launch. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns iOS with Android: a delete is never skipped, a Launch requested after it waits for the stop and wipe, and a delete joined to a stop runs ahead of the other waiters. Fixes #51 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 8 + Plugins/iOS/BugseeUnityBridge.mm | 59 +++--- Runtime/Platform/IOS/IOSBridge.cs | 81 ++++++--- .../DeleteCollectedDataLaunchGeneration.cs | 17 +- Runtime/WrapperPolicy/StopWaitList.cs | 65 ++++++- .../NetworkEventLaunchBufferTests.cs | 36 +--- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 49 +++-- Tests~/WrapperPolicy/StopWaitListTests.cs | 172 +++++++++++++++++- Tests~/iOS/BridgeTests.mm | 15 ++ 9 files changed, 372 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab75f47..b278eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ Behaviour changes to be aware of. 0.1.0 is not released, so these correct earlie not be started because the main thread did not take the call within 5 s. After 10 s the call's own completion runs with a warning while the native stop stays in flight (launches stay deferred); after 60 s the wait is abandoned and deferred launches run. +- iOS `DeleteCollectedDataOnDevice` now runs in request order, as on Android, and is no longer skipped. Before, a + delete did nothing (with a warning) when a `Launch` or `Relaunch` ran between its request and its teardown: + `Stop(() => Launch(...)); DeleteCollectedDataOnDevice();` and, after the 60 s give-up on a stop, a delete + queued behind it. Now the wipe runs first, and a `Launch` or `Relaunch` requested after it waits until the SDK + reports the wipe finished (it clears its files on a background thread), with the same 60 s give-up as a stop. + A delete that shares a stop with `Stop(() => Launch(...))` runs ahead of that completion. A delete that finds + the SDK still stopping waits for that stop (up to 10 s) instead of stopping it again; if it never + stops, or the SDK reports the wipe failed, a warning is logged and a waiting `Launch` still proceeds. - Android `Launch`, `Relaunch` and `DeleteCollectedDataOnDevice` run one at a time, in request order, on the main thread, and `Stop` is never queued (calls from other threads are asynchronous): a `Launch` or `Relaunch` during a stop is deferred, a `Relaunch` is ignored with a warning unless the SDK is `Launched`, a refused `Launch` no longer cancels a pending diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 42ea278..7034d57 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -550,33 +550,50 @@ void _bugsee_free(char *ptr) } } -typedef int (*BugseeDeleteCollectedDataShouldRunFn)(int capturedGeneration); +// Deletes after the SDK is stopped, and reports the token back once the SDK says the wipe finished (its files +// are cleared on a background thread), so the managed side can hold a following Launch until then. wiped is 1 +// when the SDK reported the wipe done, 0 when it failed or never ran (the SDK did not finish stopping). +typedef void (*BugseeDeleteCompletionFn)(int token, int wiped); -void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) +void _bugsee_delete_collected_data(int token, BugseeDeleteCompletionFn completion) { BugseeRunOnMain(^{ - BOOL (^allowed)(void) = ^BOOL { - return !shouldRun || shouldRun(capturedGeneration); - }; - void (^runDeleteIfAllowed)(void) = ^{ - if (!allowed()) { - return; - } - [Bugsee deleteCollectedDataOnDevice:YES completion:nil]; + void (^runDelete)(void) = ^{ + [Bugsee deleteCollectedDataOnDevice:YES completion:^(BOOL success) { + if (completion) { + completion(token, success ? 1 : 0); + } + }]; }; - // A Launch since the delete was requested owns the SDK now: do not stop it. - if (!allowed()) { - return; - } // Only a running SDK is stopped: on a stopped one the stop's block may land after a launch that follows // this call on the main thread, and the wipe would then be skipped. Bugsee *instance = [Bugsee sharedInstance]; - if (instance != nil && instance.status != BugseeStatusStopped) { + BugseeStatus status = instance != nil ? instance.status : BugseeStatusStopped; + if (status == BugseeStatusStopping) { + // A stop is already running (the managed side gave up waiting for it): a second [Bugsee stop:] could + // land on a launch made after that, so wait for the stop that is running instead. + __block void (^waitForStopped)(int) = nil; + waitForStopped = ^(int remainingTries) { + if (instance.status == BugseeStatusStopping && remainingTries > 0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(100 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{ + waitForStopped(remainingTries - 1); + }); + return; + } + if (instance.status == BugseeStatusStopped) { + runDelete(); + } else if (completion) { + completion(token, 0); + } + waitForStopped = nil; + }; + waitForStopped(100); + } else if (status != BugseeStatusStopped) { [Bugsee stop:^{ - runDeleteIfAllowed(); + runDelete(); }]; } else { - runDeleteIfAllowed(); + runDelete(); } }); } @@ -652,7 +669,6 @@ void _bugsee_upload_managed_report(const char *reportJson, #else // !BUGSEE_IOS_SDK typedef void (*BugseeManagedReportCreateCallback)(int succeeded, uint64_t uploadToken); -typedef int (*BugseeDeleteCollectedDataShouldRunFn)(int capturedGeneration); extern "C" { @@ -706,11 +722,8 @@ void _bugsee_feedback_show(void) {} void _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { (void)propertyName; (void)propertyValue; } char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } -void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) -{ - (void)capturedGeneration; - (void)shouldRun; -} +typedef void (*BugseeDeleteCompletionFn)(int token, int wiped); +void _bugsee_delete_collected_data(int token, BugseeDeleteCompletionFn completion) { if (completion) completion(token, 0); } void _bugsee_cancel_managed_report_upload(uint64_t uploadId) { (void)uploadId; } void _bugsee_invalidate_managed_report_uploads(void) {} void _bugsee_upload_managed_report(const char *reportJson, diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index ab7217f..723ed8b 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -25,8 +25,8 @@ namespace Bugsee.Platform.IOS sealed class IOSBridge : IBugseeNativeBridge, ILifecycleResultBridge { delegate void ManagedReportUploadNativeCallback(int succeeded, ulong uploadToken); - delegate int DeleteCollectedDataShouldRunNativeCallback(int capturedGeneration); delegate void StopNativeCallback(int token); + delegate void DeleteNativeCallback(int token, int wiped); delegate void LaunchStartedNativeCallback(int token, int success); ulong _inFlightUploadToken; @@ -240,10 +240,10 @@ static void ArmStopSoftDeadline(int token) // Main thread only (inside a lifecycle closure). True when then waits on a native stop in flight; false // when it has already run. - bool StopNativeThen(Action then, Action dropped = null) + bool StopNativeThen(Action then, Action dropped = null, bool first = false) { var generation = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - var joined = StopWaiters.TryJoin(then, generation, out var cancelledLaunch, dropped); + var joined = StopWaiters.TryJoin(then, generation, out var cancelledLaunch, dropped, first); cancelledLaunch?.Invoke(false); if (joined) return true; // Already stopped: nothing will be reported, so run now. ([Bugsee stop:] on a stopped SDK still @@ -255,7 +255,7 @@ bool StopNativeThen(Action then, Action dropped = null) return false; } - var token = StopWaiters.Begin(then, generation, dropped); + var token = StopWaiters.Begin(then, generation, dropped, first); GiveUpIfStopNotReported(token); _bugsee_stop(token, OnNativeStopped); return true; @@ -340,6 +340,26 @@ static void OnNativeStopped(int token) } } + // The wipe finished (wiped) or did not happen. The deferred launch is released either way, so nothing waits + // forever; a wipe that did not happen is logged, as DeleteCollectedDataOnDevice has no result to carry it. + [MonoPInvokeCallback(typeof(DeleteNativeCallback))] + static void OnNativeDeleted(int token, int wiped) + { + try + { + MainThreadDispatcher.Run(() => + { + if (wiped == 0) + Debug.LogWarning("[Bugsee] DeleteCollectedDataOnDevice did not wipe the collected data: the SDK reported a failure or had not finished stopping."); + CompleteStop(token); + }); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + } + // 0 when nobody waits for the result, so the plain Launch keeps the SDK call without a started block. // If the SDK never answers, the result is read from its status at the deadline. int RegisterLaunchResult(Action started) @@ -426,40 +446,45 @@ public void NotifyLifecycle(string eventType) public void DeleteCollectedDataOnDevice() { - var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); // One path, decided inside the main-thread closure and not on the SDK's state: Launch always installs // the managed hooks, even when the SDK then refuses it (status stays Stopped), so they are torn down // unconditionally (uninstalling is idempotent), and the SDK's status is not read off-thread. The // delete waits on the shared native stop (joining one already in flight); the native delete only - // stops an SDK that is not already stopped. + // stops an SDK that is not already stopped. Requests run in order, as on Android: a delete is never + // skipped because a Launch follows it (that Launch waits for the stop and so runs after the wipe), and + // one requested after a Launch wipes that session. if (!MainThreadDispatcher.RunSyncLifecycle(() => { - // A Launch/Relaunch since the delete was requested (queued ahead of this closure, or on the - // main thread while a background caller waited) owns the SDK now: tearing it down would stop - // the new session, and the wipe would be skipped anyway. - if (!DeleteCollectedDataLaunchGeneration.RunIfCurrent(deleteGeneration, () => - { - HostLogForwarder.Uninstall(); - ExceptionPipeline.Uninstall(); - CancelManagedReportUpload(); - _openReport?.ReleaseSnapshotFiles(); - _openReport = null; - _openReportHandle = null; - _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); - StopNativeThen(() => - _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative)); - })) - Debug.LogWarning("[Bugsee] DeleteCollectedDataOnDevice was overtaken by a later Launch; nothing was deleted."); + HostLogForwarder.Uninstall(); + ExceptionPipeline.Uninstall(); + CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + if (StopWaiters.TryCoalesceWipe()) return; + StopNativeThen(BeginNativeDelete, first: true); })) { Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); } } - [MonoPInvokeCallback(typeof(DeleteCollectedDataShouldRunNativeCallback))] - static int ShouldRunDeleteCollectedDataNative(int capturedGeneration) => - DeleteCollectedDataLaunchGeneration.ShouldRunDelete(capturedGeneration) ? 1 : 0; + // Main thread only. The wipe is a native operation in flight like a stop: a Launch or Relaunch requested + // meanwhile waits for the SDK to report it finished (it clears its files on a background thread), so a + // new session never starts, or has capture files deleted, under a running wipe. Same hard deadline as a + // stop, so nothing waits forever; no soft one, as no caller waits for the wipe. + // A Delete that follows one still wiping is the same wipe, so it starts no second one (two would release a + // deferred Launch at the later one's report, under the first one's file pass). + static void BeginNativeDelete() + { + if (StopWaiters.TryCoalesceWipe()) return; + var generation = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + var token = StopWaiters.Begin(null, generation, holdsLaunch: true); + GiveUpIfStopNotReported(token); + _bugsee_delete_collected_data(token, OnNativeDeleted); + } void CancelManagedReportUpload() { @@ -822,9 +847,7 @@ static IDictionary WithWrapperInfo(IDictionary o string message, int iosLevel, double timestampUnixSeconds); - [DllImport("__Internal")] static extern void _bugsee_delete_collected_data( - int capturedGeneration, - DeleteCollectedDataShouldRunNativeCallback shouldRun); + [DllImport("__Internal")] static extern void _bugsee_delete_collected_data(int token, DeleteNativeCallback completion); [DllImport("__Internal")] static extern void _bugsee_cancel_managed_report_upload(ulong uploadId); [DllImport("__Internal")] static extern void _bugsee_invalidate_managed_report_uploads(); [DllImport("__Internal")] static extern void _bugsee_upload_managed_report( diff --git a/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs index e7b43e9..9259899 100644 --- a/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs +++ b/Runtime/WrapperPolicy/DeleteCollectedDataLaunchGeneration.cs @@ -4,7 +4,8 @@ namespace Bugsee.WrapperPolicy { /// - /// Launch/Relaunch bumps generation so an in-flight GDPR delete can skip wiping a newer session. + /// Launch/Relaunch bumps the generation, so a Stop completion can tell that a new session started after it + /// was queued. A delete does not use it: a delete is never skipped, it runs in request order. /// internal static class DeleteCollectedDataLaunchGeneration { @@ -13,19 +14,5 @@ internal static class DeleteCollectedDataLaunchGeneration public static int CaptureForPendingDelete() => Volatile.Read(ref _generation); public static void BumpForLaunch() => Interlocked.Increment(ref _generation); - - public static bool ShouldRunDelete(int capturedGeneration) => - capturedGeneration == Volatile.Read(ref _generation); - - /// - /// Runs a delete's teardown only if no Launch/Relaunch has happened since it was requested; false when it - /// was skipped. Main thread, where launches bump the generation, so the check and the body are not split. - /// - public static bool RunIfCurrent(int capturedGeneration, Action body) - { - if (!ShouldRunDelete(capturedGeneration)) return false; - body(); - return true; - } } } diff --git a/Runtime/WrapperPolicy/StopWaitList.cs b/Runtime/WrapperPolicy/StopWaitList.cs index cbcbe21..70e45fb 100644 --- a/Runtime/WrapperPolicy/StopWaitList.cs +++ b/Runtime/WrapperPolicy/StopWaitList.cs @@ -14,10 +14,15 @@ namespace Bugsee.WrapperPolicy /// A Launch/Relaunch that arrives while a native stop is in flight is deferred until it finishes, because /// the SDK keeps a launch that completes after a stop stopped. The latest request wins: a later /// Launch/Relaunch replaces a deferred one, and a later Stop or Delete cancels it. When the stop finishes the - /// waiters run in arrival order, then the deferred launch, which is by construction the latest request; it + /// waiters run in arrival order (first waiters ahead of the rest), then the deferred launch, which is by construction the latest request; it /// is cancelled instead if a waiter has made a newer request meanwhile (a completion that launches or stops /// again was called later still). A cancelled launch is told so (false), so nothing waits on it. /// + /// + /// A Delete joins as a first waiter: it runs before the other waiters and is never dropped, because a + /// wipe must happen in request order and not silently not happen. A completion that relaunches (a Stop + /// requested before the Delete) was by request order a later request, so the wipe goes ahead of it. + /// /// internal sealed class StopWaitList { @@ -25,11 +30,13 @@ sealed class Waiter { public Action Run; public Action Dropped; + public bool First; } sealed class Pending { public int Generation; + public bool HoldsLaunch; public readonly List Waiters = new List(); } @@ -49,7 +56,8 @@ sealed class Pending /// A deferred launch is cancelled either way (it comes back in , for /// the caller to call with false outside any lock): this request is newer. /// - public bool TryJoin(Action waiter, int generation, out Action cancelledLaunch, Action dropped = null) + public bool TryJoin(Action waiter, int generation, out Action cancelledLaunch, Action dropped = null, + bool first = false) { lock (_gate) { @@ -59,26 +67,49 @@ public bool TryJoin(Action waiter, int generation, out Action cancelledLau || !_pending.TryGetValue(_inFlightToken, out var pending) || pending.Generation != generation) return false; - Add(pending, waiter, dropped); + Add(pending, waiter, dropped, first); return true; } } - /// Records a new native stop in flight and returns the token to pass to it. - public int Begin(Action waiter, int generation, Action dropped = null) + /// + /// Records a new native operation in flight (a stop, or with a data wipe) + /// and returns the token to pass to it. A wipe is the same request as the Delete that started it, so it + /// does not count as a newer one: a launch deferred behind it stays deferred, and runs when the wipe is + /// reported (), not when the stop before it finished. + /// + public int Begin(Action waiter, int generation, Action dropped = null, bool first = false, + bool holdsLaunch = false) { lock (_gate) { - _intent++; + if (!holdsLaunch) _intent++; var token = ++_lastToken; - var pending = new Pending { Generation = generation }; - Add(pending, waiter, dropped); + var pending = new Pending { Generation = generation, HoldsLaunch = holdsLaunch }; + Add(pending, waiter, dropped, first); _pending[token] = pending; _inFlightToken = token; return token; } } + /// + /// True when a wipe begun with holdsLaunch is the operation in flight and no launch is waiting on + /// it: a consecutive Delete is then the same wipe (Android coalesces them too), so the caller starts no + /// second one. Not a new request, so the intent is unchanged. False when a launch is deferred behind the + /// wipe: a Delete after that launch is a newer request and joins as usual, cancelling it. + /// + public bool TryCoalesceWipe() + { + lock (_gate) + { + return _inFlightToken != 0 + && _deferredLaunch == null + && _pending.TryGetValue(_inFlightToken, out var pending) + && pending.HoldsLaunch; + } + } + /// /// Defers until the native stop in flight finishes. False when none is in /// flight: the caller launches now. A launch deferred earlier is replaced and comes back in @@ -126,6 +157,12 @@ public bool Complete(int token, Func currentGeneration, Action run) var dropped = false; foreach (var waiter in pending.Waiters) { + if (!waiter.First) continue; + if (waiter.Run != null) run(waiter.Run); + } + foreach (var waiter in pending.Waiters) + { + if (waiter.First) continue; if (!dropped && currentGeneration() != pending.Generation) dropped = true; var action = dropped ? waiter.Dropped : waiter.Run; if (action != null) run(action); @@ -136,6 +173,14 @@ public bool Complete(int token, Func currentGeneration, Action run) bool latest; lock (_gate) { + if (_intent == launchIntent && _inFlightToken != 0 + && _pending.TryGetValue(_inFlightToken, out var next) && next.HoldsLaunch) + { + // A waiter started the wipe: the launch waits for it. + _deferredLaunch = launch; + _deferredLaunchIntent = launchIntent; + return dropped; + } latest = _intent == launchIntent && _inFlightToken == 0; } run(() => launch(latest)); @@ -152,10 +197,10 @@ public bool IsPending(int token) } } - static void Add(Pending pending, Action waiter, Action dropped) + static void Add(Pending pending, Action waiter, Action dropped, bool first) { if (waiter != null || dropped != null) - pending.Waiters.Add(new Waiter { Run = waiter, Dropped = dropped }); + pending.Waiters.Add(new Waiter { Run = waiter, Dropped = dropped, First = first }); } Action TakeDeferredLaunch() diff --git a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs index 6356e22..554952b 100644 --- a/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -8,43 +8,11 @@ namespace Bugsee.WrapperPolicy.Tests public class NetworkEventLaunchBufferTests { [Test] - public void Delete_generation_launch_bump_invalidates_pending_delete() + public void Delete_generation_launch_bump_changes_the_captured_generation() { var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); DeleteCollectedDataLaunchGeneration.BumpForLaunch(); - Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(captured), Is.False); - } - - [Test] - public void Delete_generation_runs_when_generation_matches_current() - { - var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(captured), Is.True); - DeleteCollectedDataLaunchGeneration.BumpForLaunch(); - var afterLaunch = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - Assert.That(DeleteCollectedDataLaunchGeneration.ShouldRunDelete(afterLaunch), Is.True); - } - - [Test] - public void A_delete_whose_generation_went_stale_before_its_body_runs_does_nothing() - { - // Delete captured on a background thread, then a Launch runs on the main thread before the Delete's - // closure: the teardown (and its native stop) must not run against the new session. - var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - DeleteCollectedDataLaunchGeneration.BumpForLaunch(); - var ran = false; - Assert.That(DeleteCollectedDataLaunchGeneration.RunIfCurrent(captured, () => ran = true), Is.False); - Assert.That(ran, Is.False); - } - - [Test] - public void A_delete_in_its_own_generation_runs_its_body() - { - // Includes a launch the SDK refused in the same generation: its hooks still need tearing down. - var captured = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); - var ran = false; - Assert.That(DeleteCollectedDataLaunchGeneration.RunIfCurrent(captured, () => ran = true), Is.True); - Assert.That(ran, Is.True); + Assert.That(DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(), Is.Not.EqualTo(captured)); } [Test] diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 350eacf..9aa82cb 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -94,13 +94,13 @@ public void Ios_delete_collected_data_body_uses_main_thread_hop() string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); Assert.That(body, Does.Contain("BugseeRunOnMain")); Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); - // A delete made stale by a Launch must not stop the new session, so the check precedes the stop. - int check = body.IndexOf("if (!allowed())", StringComparison.Ordinal); - int stop = body.IndexOf("[Bugsee stop:", StringComparison.Ordinal); - Assert.That(check, Is.GreaterThanOrEqualTo(0)); - Assert.That(stop, Is.GreaterThan(check)); + // A delete is never skipped (request order, as on Android): no generation check in the native part. + Assert.That(body, Does.Not.Contain("shouldRun")); + Assert.That(body, Does.Not.Contain("capturedGeneration")); // Only a running SDK is stopped, so the wipe on an already stopped one runs before a launch that follows. - Assert.That(body, Does.Contain("instance.status != BugseeStatusStopped")); + Assert.That(body, Does.Contain("status != BugseeStatusStopped")); + // ...and a stop already running (Stopping) is waited for, not stopped a second time. + Assert.That(body, Does.Contain("status == BugseeStatusStopping")); } [Test] @@ -454,14 +454,34 @@ public void Ios_delete_chains_stop_completion_before_delete_when_instance_exists string body = ExtractNativeFunctionBody(bridge, "_bugsee_delete_collected_data"); Assert.That(body, Does.Contain("[Bugsee stop:")); Assert.That(body, Does.Contain("deleteCollectedDataOnDevice:YES")); - Assert.That(body, Does.Contain("shouldRun(capturedGeneration)")); Assert.That(body, Does.Contain("sharedInstance")); string iosDelete = ExtractMethodBody( File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), "public void DeleteCollectedDataOnDevice()"); - Assert.That(iosDelete, Does.Contain("CaptureForPendingDelete()")); - Assert.That(iosDelete, Does.Contain("ShouldRunDeleteCollectedDataNative")); + Assert.That(iosDelete, Does.Not.Contain("CaptureForPendingDelete")); + Assert.That(iosDelete, Does.Not.Contain("RunIfCurrent")); + + // The wipe is a native operation in flight that holds a following launch until it is reported. + string beginDelete = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "static void BeginNativeDelete()"); + Assert.That(beginDelete, Does.Contain("holdsLaunch: true")); + Assert.That(beginDelete, Does.Contain("if (StopWaiters.TryCoalesceWipe()) return;")); + Assert.That(iosDelete, Does.Contain("if (StopWaiters.TryCoalesceWipe()) return;")); + Assert.That(beginDelete, Does.Contain("GiveUpIfStopNotReported(token)")); + Assert.That(beginDelete, Does.Contain("_bugsee_delete_collected_data(token, OnNativeDeleted)")); + Assert.That(body, Does.Contain("completion(token, success ? 1 : 0)")); + // The timeout path reports "not wiped" instead of ordinary completion, and managed code warns about it + // but still releases the deferred launch. + Assert.That(body, Does.Contain("completion(token, 0)")); + string deleted = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")), + "static void OnNativeDeleted(int token, int wiped)"); + Assert.That(deleted, Does.Contain("if (wiped == 0)")); + Assert.That(deleted, Does.Contain("Debug.LogWarning")); + Assert.That(deleted, Does.Contain("CompleteStop(token)")); + Assert.That(iosDelete, Does.Contain("StopNativeThen(BeginNativeDelete, first: true)")); } [Test] @@ -566,8 +586,8 @@ public void Ios_network_buffer_wiring() Assert.That(ios, Does.Contain("const int StopSoftDeadlineMs = 10000;")); // One native stop at a time, shared by Stop and Delete. Only the hard deadline gives up on it. - string stopNative = ExtractMethodBody(ios, "bool StopNativeThen(Action then, Action dropped = null)"); - Assert.That(stopNative, Does.Contain("StopWaiters.TryJoin(then, generation, out var cancelledLaunch, dropped)")); + string stopNative = ExtractMethodBody(ios, "bool StopNativeThen(Action then, Action dropped = null, bool first = false)"); + Assert.That(stopNative, Does.Contain("StopWaiters.TryJoin(then, generation, out var cancelledLaunch, dropped, first)")); Assert.That(stopNative, Does.Contain("cancelledLaunch?.Invoke(false)")); Assert.That(stopNative, Does.Contain("GiveUpIfStopNotReported(token)")); Assert.That(stopNative, Does.Contain("_bugsee_stop(token, OnNativeStopped)")); @@ -596,11 +616,8 @@ public void Ios_network_buffer_wiring() // even when the SDK then refuses it, and the SDK's status must not be read off the main thread. string delete = ExtractMethodBody(ios, "public void DeleteCollectedDataOnDevice()"); Assert.That(delete, Does.Contain("RunSyncLifecycle")); - // ...but only for the generation it was requested in: the fence comes before any teardown or stop. - int fence = delete.IndexOf("DeleteCollectedDataLaunchGeneration.RunIfCurrent(deleteGeneration,", StringComparison.Ordinal); - Assert.That(fence, Is.GreaterThan(delete.IndexOf("RunSyncLifecycle", StringComparison.Ordinal))); - Assert.That(fence, Is.LessThan(delete.IndexOf("HostLogForwarder.Uninstall()", StringComparison.Ordinal))); - Assert.That(fence, Is.LessThan(delete.IndexOf("StopNativeThen(", StringComparison.Ordinal))); + // ...and in request order: no generation fence skips it when a Launch follows. + Assert.That(delete, Does.Not.Contain("DeleteCollectedDataLaunchGeneration")); Assert.That(delete, Does.Contain("HostLogForwarder.Uninstall()")); Assert.That(delete, Does.Contain("ExceptionPipeline.Uninstall()")); Assert.That(delete, Does.Not.Contain("GetStatus()")); diff --git a/Tests~/WrapperPolicy/StopWaitListTests.cs b/Tests~/WrapperPolicy/StopWaitListTests.cs index 07afa2e..6ee21b4 100644 --- a/Tests~/WrapperPolicy/StopWaitListTests.cs +++ b/Tests~/WrapperPolicy/StopWaitListTests.cs @@ -74,18 +74,184 @@ public void Waiters_are_dropped_when_a_launch_happened_before_the_report() [Test] public void A_launch_made_by_an_earlier_waiter_drops_the_later_ones() { - // Stop(() => Launch(token)); DeleteCollectedDataOnDevice(); -- the joined delete must not stop the - // session the first completion just launched. + // Stop(() => Launch(token)); Stop(completion); -- the second completion belongs to the session that + // stopped and must not act on the one the first just launched. var list = new StopWaitList(); var generation = 1; var ran = new List(); var token = list.Begin(() => { ran.Add("launch"); generation++; }, 1); - Assert.That(TryJoin(list, () => ran.Add("delete"), 1), Is.True); + Assert.That(TryJoin(list, () => ran.Add("second"), 1), Is.True); Assert.That(list.Complete(token, () => generation, Run), Is.True); Assert.That(ran, Is.EqualTo(new[] { "launch" })); } + [Test] + public void A_joined_delete_runs_ahead_of_a_completion_that_launches() + { + // Stop(() => Launch(token)); DeleteCollectedDataOnDevice(); -- the Launch is a later request than the + // Delete, so the wipe runs first and is not skipped, as on Android. + var list = new StopWaitList(); + var generation = 1; + var ran = new List(); + var token = list.Begin(() => { ran.Add("launch"); generation++; }, 1); + Assert.That(list.TryJoin(() => ran.Add("delete"), 1, out var cancelled, null, first: true), Is.True); + Assert.That(cancelled, Is.Null); + + Assert.That(list.Complete(token, () => generation, Run), Is.False); + Assert.That(ran, Is.EqualTo(new[] { "delete", "launch" })); + } + + [Test] + public void A_delete_is_not_dropped_when_a_launch_happened_before_the_report() + { + var list = new StopWaitList(); + var ran = false; + var token = list.Begin(() => ran = true, 1, null, first: true); + + Assert.That(list.Complete(token, () => 2, Run), Is.False); + Assert.That(ran, Is.True); + } + + // Native side of a delete, as the bridge does it: a waiter of the stop begins a wipe in flight. + static int BeginWipe(StopWaitList list) => list.Begin(null, 1, null, holdsLaunch: true); + + [Test] + public void A_launch_after_a_delete_waits_for_the_wipe_to_be_reported_not_the_stop() + { + // Delete(); Launch(); with a stop in flight: the launch runs after the wipe has finished, which the + // SDK reports later than the stop (it clears its files on a background thread). + var list = new StopWaitList(); + var ran = new List(); + var wipe = 0; + var stop = list.Begin(() => { ran.Add("wipe starts"); wipe = BeginWipe(list); }, 1, null, first: true); + Assert.That(TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")), Is.True); + + list.Complete(stop, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "wipe starts" }), "the stop's report must not release the launch"); + + list.Complete(wipe, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "wipe starts", "launch" })); + } + + [Test] + public void A_launch_after_a_delete_on_a_stopped_sdk_waits_for_the_wipe() + { + var list = new StopWaitList(); + var ran = new List(); + var wipe = BeginWipe(list); + + Assert.That(TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")), Is.True); + Assert.That(ran, Is.Empty); + list.Complete(wipe, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "launch" })); + } + + // What BeginNativeDelete does: no second wipe while one is in flight. + static int BeginWipeOnce(StopWaitList list, List ran) + { + if (list.TryCoalesceWipe()) { ran.Add("coalesced"); return 0; } + ran.Add("wipe"); + return BeginWipe(list); + } + + [Test] + public void Two_deletes_sharing_a_stop_start_one_wipe_and_the_launch_waits_for_it() + { + // Delete(); Delete(); Launch(); while the SDK runs. + var list = new StopWaitList(); + var ran = new List(); + var wipe = 0; + var stop = list.Begin(() => wipe = BeginWipeOnce(list, ran), 1, null, first: true); + Assert.That(list.TryJoin(() => BeginWipeOnce(list, ran), 1, out _, null, first: true), Is.True); + TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")); + + list.Complete(stop, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "wipe", "coalesced" }), "launch stays held"); + list.Complete(wipe, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "wipe", "coalesced", "launch" })); + } + + [Test] + public void Without_coalescing_two_first_waiters_would_release_the_launch_at_the_later_wipe() + { + var list = new StopWaitList(); + var ran = new List(); + int w1 = 0, w2 = 0; + var stop = list.Begin(() => w1 = BeginWipe(list), 1, null, first: true); + list.TryJoin(() => w2 = BeginWipe(list), 1, out _, null, first: true); + TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")); + list.Complete(stop, () => 1, Run); + + list.Complete(w2, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "launch" }), "the launch ran while the first wipe was still going"); + Assert.That(w1, Is.Not.EqualTo(w2)); + } + + [Test] + public void A_delete_on_a_stopped_sdk_right_after_a_delete_coalesces_into_its_wipe() + { + // Already stopped: the first delete begins its wipe at once; the second finds it in flight. + var list = new StopWaitList(); + var ran = new List(); + var wipe = BeginWipeOnce(list, ran); + Assert.That(BeginWipeOnce(list, ran), Is.EqualTo(0)); + TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")); + + list.Complete(wipe, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "wipe", "coalesced", "launch" })); + } + + [Test] + public void A_delete_after_a_deferred_launch_does_not_coalesce_and_cancels_it() + { + // Delete(); Launch(); Delete(); -- the last delete is newer than the launch. + var list = new StopWaitList(); + var ran = new List(); + var wipe = BeginWipe(list); + TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")); + + Assert.That(list.TryCoalesceWipe(), Is.False); + Assert.That(list.TryJoin(() => ran.Add("second wipe"), 1, out var cancelled, null, first: true), Is.True); + cancelled?.Invoke(false); + list.Complete(wipe, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "cancelled", "second wipe" })); + } + + [Test] + public void A_stop_in_flight_is_not_a_wipe_to_coalesce_into() + { + var list = new StopWaitList(); + list.Begin(() => { }, 1); + Assert.That(list.TryCoalesceWipe(), Is.False); + } + + [Test] + public void A_newer_request_still_cancels_a_launch_held_behind_a_wipe() + { + var list = new StopWaitList(); + var ran = new List(); + var wipe = BeginWipe(list); + TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")); + TryDeferLaunch(list, run => ran.Add(run ? "launch2" : "cancelled2")); + + list.Complete(wipe, () => 1, Run); + Assert.That(ran, Is.EqualTo(new[] { "cancelled", "launch2" })); + } + + [Test] + public void A_wipe_the_bridge_gives_up_on_still_releases_the_launch() + { + var list = new StopWaitList(); + var ran = new List(); + var wipe = BeginWipe(list); + TryDeferLaunch(list, run => ran.Add(run ? "launch" : "cancelled")); + + list.Complete(wipe, () => 1, Run); // the deadline calls Complete too + list.Complete(wipe, () => 1, Run); // a late report is ignored + Assert.That(ran, Is.EqualTo(new[] { "launch" })); + } + [Test] public void A_dropped_waiter_runs_its_dropped_action_so_nothing_waits_forever() { diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index 1ae9cfc..ebed672 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -18,6 +18,8 @@ void _bugsee_relaunch(const char *optionsJson, int startedToken, BugseeLaunchStartedFn started); typedef void (*BugseeStopCompletionFn)(int token); void _bugsee_stop(int token, BugseeStopCompletionFn completion); +typedef void (*BugseeDeleteCompletionFn)(int token, int wiped); +void _bugsee_delete_collected_data(int token, BugseeDeleteCompletionFn completion); } static int gStartedToken; @@ -31,6 +33,15 @@ static void OnStarted(int token, int success) gStartedCount++; } +static int gDeletedToken; +static int gDeletedWiped = -1; + +static void OnDeleted(int token, int wiped) +{ + gDeletedToken = token; + gDeletedWiped = wiped; +} + static void OnStopped(int token) { gStoppedToken = token; @@ -120,6 +131,10 @@ int main(void) _bugsee_relaunch(NULL, 42, OnStarted); expect(waitFor(^{ return gStartedCount == 2 ? 1 : 0; }, 5) && gStartedToken == 42, "relaunch reports the SDK's started answer with its token"); + // The wipe reports back with its token once the SDK has finished it (it stops a running SDK first). + _bugsee_delete_collected_data(9, OnDeleted); + expect(waitFor(^{ return gDeletedToken == 9 ? 1 : 0; }, 15), "delete reports its completion with its token"); + expect(gDeletedWiped == 1, "delete reports wiped on the normal path"); _bugsee_launch("00000000-0000-0000-0000-000000000000", NULL, 0, NULL); expect(gStartedCount == 2, "a launch without a started callback calls nothing back"); From 48e1ce29ede78b68a6a1b4c74f74103a8d15ed73 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 23:06:20 +0500 Subject: [PATCH 104/157] Support Kotlin-DSL Gradle files and repositoriesMode in the Android Maven injector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #48 (partially: Unity 2022/6 builds remain unverified). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + DESIGN.md | 2 +- Editor/BugseeAndroidMavenDependencies.cs | 77 ++-- Runtime/WrapperPolicy/GradleMavenInjection.cs | 312 ++++++++++++- Tests~/WrapperPolicy/GradleKotlinDslTests.cs | 421 ++++++++++++++++++ .../GradleMavenInjectionTests.cs | 3 + 6 files changed, 768 insertions(+), 48 deletions(-) create mode 100644 Tests~/WrapperPolicy/GradleKotlinDslTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46394f7..fe9c455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. - iOS appearance setters and getters were a no-op because `+[Bugsee getAppearance]` returns a `BugseeTheme`, not a `BGSAppearance`. They now map onto `BugseeTheme` (report/feedback colors, report placeholders). Properties with no iOS equivalent are skipped and read back null: Report `ActionBarTextColor`, `ActionBarButtonBackgroundClickedColor`, `SeverityLabelActiveColor`; Feedback `ActionBarButtonBackgroundClickedColor`, `DateTextColor`, `EmailSkipBackgroundClickedColor`, `EmailContinueClickedColor`, `InputTextHintColor`, `BottomDelimiterColor`, `LoadingBarBackgroundColor`, `LoadingTextColor`, `ErrorTextColor`, `VersionChangedBackgroundColor`, `VersionChangedTextColor`; `Notification::Title`. ### Added diff --git a/DESIGN.md b/DESIGN.md index 5fd0cb5..c7adbfe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -89,7 +89,7 @@ Property / key naming currently follows **Android 7.0**. When the Bugsee **iOS** ## Native packaging (already scaffolded) - `package.json` → `com.bugsee.unity`, no package dependencies -- `Editor/BugseeAndroidMavenDependencies.cs` → declares Maven 7.4.1 (+ NDK, both explicit) in the generated Gradle project; text edits in `Runtime/WrapperPolicy/GradleMavenInjection.cs`. A project-declared Bugsee dependency (EDM4U, custom template, local AAR) is kept and a version mismatch warned about +- `Editor/BugseeAndroidMavenDependencies.cs` → declares Maven 7.4.1 (+ NDK, both explicit) in the generated Gradle project; text edits in `Runtime/WrapperPolicy/GradleMavenInjection.cs`. A project-declared Bugsee dependency (EDM4U, custom template, local AAR) is kept and a version mismatch warned about; `.gradle.kts` files (project-converted; Unity generates Groovy) are edited in Kotlin syntax and `repositoriesMode` (`FAIL_ON_PROJECT_REPOS`) is respected - `Editor/BugseeIosSpmPostProcess.cs` → remote SPM `github.com/bugsee/spm` `7.0.0-beta6` - `Plugins/iOS/BugseeUnityBridge.mm` + `Plugins/Android/UnityManagedException.java` - `Tools~/scripts/update-native-sdks.sh` + `versions.env` diff --git a/Editor/BugseeAndroidMavenDependencies.cs b/Editor/BugseeAndroidMavenDependencies.cs index 65ea6b6..de7afd1 100644 --- a/Editor/BugseeAndroidMavenDependencies.cs +++ b/Editor/BugseeAndroidMavenDependencies.cs @@ -19,7 +19,12 @@ namespace Bugsee.Editor /// local AAR) is kept and nothing is added next to it; a version that differs from /// is reported. mavenCentral() is added where the /// generated project declares its repositories (settings.gradle dependencyResolutionManagement, or the - /// root build.gradle allprojects block) if it is missing. + /// root build.gradle allprojects block) if it is missing; a project that sets + /// FAIL_ON_PROJECT_REPOS is never given project-level repositories. + /// + /// Unity 2021.3 through Unity 6 generate Groovy files, but a project that post-processes them to the Kotlin + /// DSL (build.gradle.kts, settings.gradle.kts) is handled too: the Groovy file is used when both + /// exist, and dependencies are written as implementation("g:a:v") in a .kts file. /// /// The edits themselves are (unit-tested); this class only reads and writes /// the files. Not behind a UNITY_ANDROID guard: IPostGenerateGradleAndroidProject is defined in @@ -53,14 +58,31 @@ public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) } } + static readonly string[] GradleExtensions = { ".gradle", ".gradle.kts" }; + + /// The existing name.gradle or name.gradle.kts in (Groovy first), or null. + static string FindGradleFile(string dir, string name) + { + foreach (string extension in GradleExtensions) + { + string path = Path.Combine(dir, name + extension); + if (File.Exists(path)) + { + return path; + } + } + + return null; + } + static void InjectDependencies(string unityLibraryPath) { - string path = Path.Combine(unityLibraryPath, "build.gradle"); - if (!File.Exists(path)) + string path = FindGradleFile(unityLibraryPath, "build"); + if (path == null) { throw new BuildFailedException( - "[Bugsee] " + path + " not found; cannot declare com.bugsee:bugsee-android. " + - "(Kotlin DSL Gradle files are not supported.)"); + "[Bugsee] Neither build.gradle nor build.gradle.kts found in " + unityLibraryPath + + "; cannot declare com.bugsee:bugsee-android."); } string text = File.ReadAllText(path); @@ -68,7 +90,8 @@ static void InjectDependencies(string unityLibraryPath) text, BugseePackageVersion.AndroidMavenGroup, Artifacts, - BugseePackageVersion.AndroidSdkVersion); + BugseePackageVersion.AndroidSdkVersion, + GradleMavenInjection.DslForFile(path)); foreach (string warning in result.Warnings) { @@ -90,39 +113,33 @@ static void InjectDependencies(string unityLibraryPath) static void EnsureMavenCentral(string rootPath) { - var settings = Path.Combine(rootPath, "settings.gradle"); - if (TryEnsureMavenCentral(settings, "dependencyResolutionManagement")) - { - return; - } + string settingsPath = FindGradleFile(rootPath, "settings"); + string rootBuildPath = FindGradleFile(rootPath, "build"); + string settings = settingsPath == null ? null : File.ReadAllText(settingsPath); + string rootBuild = rootBuildPath == null ? null : File.ReadAllText(rootBuildPath); - var rootBuild = Path.Combine(rootPath, "build.gradle"); - if (TryEnsureMavenCentral(rootBuild, "allprojects")) + var result = GradleMavenInjection.EnsureProjectRepositories(settings, rootBuild); + if (result.SettingsStatus == GradleMavenInjection.RepositoryStatus.Inserted) { - return; + File.WriteAllText(settingsPath, result.Settings); + Debug.Log("[Bugsee] Added mavenCentral() to dependencyResolutionManagement repositories in " + settingsPath); } - Debug.LogWarning( - "[Bugsee] Could not find the project repositories in " + settings + " or " + rootBuild + - "; make sure mavenCentral() is declared so com.bugsee:bugsee-android resolves."); - } - - static bool TryEnsureMavenCentral(string path, string outerBlock) - { - if (!File.Exists(path)) + if (result.RootBuildStatus == GradleMavenInjection.RepositoryStatus.Inserted) { - return false; + File.WriteAllText(rootBuildPath, result.RootBuild); + Debug.Log("[Bugsee] Added mavenCentral() to allprojects repositories in " + rootBuildPath); } - string text = File.ReadAllText(path); - var status = GradleMavenInjection.EnsureMavenCentral(text, outerBlock, out string updated); - if (status == GradleMavenInjection.RepositoryStatus.Inserted) + if (!result.Found) { - File.WriteAllText(path, updated); - Debug.Log("[Bugsee] Added mavenCentral() to " + outerBlock + " repositories in " + path); + string where = result.Mode == GradleMavenInjection.RepositoriesMode.FailOnProjectRepos + ? settingsPath + " sets FAIL_ON_PROJECT_REPOS but its dependencyResolutionManagement block could not be given repositories" + : "no project repositories found in " + (settingsPath ?? "settings.gradle(.kts) (missing)") + " or " + + (rootBuildPath ?? "the root build.gradle(.kts) (missing)"); + Debug.LogWarning( + "[Bugsee] " + where + "; make sure mavenCentral() is declared so com.bugsee:bugsee-android resolves."); } - - return status != GradleMavenInjection.RepositoryStatus.BlockNotFound; } } } diff --git a/Runtime/WrapperPolicy/GradleMavenInjection.cs b/Runtime/WrapperPolicy/GradleMavenInjection.cs index a42b256..e91c2b9 100644 --- a/Runtime/WrapperPolicy/GradleMavenInjection.cs +++ b/Runtime/WrapperPolicy/GradleMavenInjection.cs @@ -23,6 +23,26 @@ public static class GradleMavenInjection const string CoreArtifactToken = "bugsee-android"; const string NdkArtifactToken = "bugsee-android-ndk"; + /// The syntax of the Gradle file being edited. + public enum GradleDsl + { + /// build.gradle: implementation 'g:a:v'. + Groovy, + + /// build.gradle.kts: implementation("g:a:v"). + Kotlin, + } + + /// How dependencyResolutionManagement treats repositories declared by projects. + public enum RepositoriesMode + { + /// No repositoriesMode is set; Gradle's default, PREFER_PROJECT, applies. + Unspecified, + PreferSettings, + PreferProject, + FailOnProjectRepos, + } + public enum DependencyStatus { /// The marked dependencies were inserted or upserted. @@ -64,6 +84,28 @@ public static DependencyResult ApplyDependencies( string group, IList artifacts, string version) + { + return ApplyDependencies(buildGradle, group, artifacts, version, GradleDsl.Groovy); + } + + /// The DSL a Gradle file is written in, by its file name (.kts is Kotlin). + public static GradleDsl DslForFile(string path) + { + return path != null && path.EndsWith(".kts", StringComparison.OrdinalIgnoreCase) + ? GradleDsl.Kotlin + : GradleDsl.Groovy; + } + + /// + /// As , writing the + /// declarations in the syntax of . + /// + public static DependencyResult ApplyDependencies( + string buildGradle, + string group, + IList artifacts, + string version, + GradleDsl dsl) { if (buildGradle == null) { @@ -109,7 +151,7 @@ public static DependencyResult ApplyDependencies( if (hasRegion) { string indent = LeadingWhitespace(lines[regionStart]); - var replacement = BuildRegion(indent, group, artifacts, version); + var replacement = BuildRegion(indent, group, artifacts, version, dsl); if (RangeEquals(lines, regionStart, regionEnd, replacement)) { result.Status = DependencyStatus.AlreadyInjected; @@ -134,7 +176,7 @@ public static DependencyResult ApplyDependencies( if (open >= 0) { string indent = LeadingWhitespace(lines[open]) + " "; - lines.InsertRange(open + 1, BuildRegion(indent, group, artifacts, version)); + lines.InsertRange(open + 1, BuildRegion(indent, group, artifacts, version, dsl)); } else { @@ -145,7 +187,7 @@ public static DependencyResult ApplyDependencies( lines.Add(string.Empty); lines.Add("dependencies {"); - lines.AddRange(BuildRegion(" ", group, artifacts, version)); + lines.AddRange(BuildRegion(" ", group, artifacts, version, dsl)); lines.Add("}"); lines.Add(string.Empty); trailingNewline = false; @@ -203,12 +245,149 @@ public static RepositoryStatus EnsureMavenCentral(string gradle, string outerBlo return RepositoryStatus.Inserted; } - static List BuildRegion(string indent, string group, IList artifacts, string version) + /// + /// The repositoriesMode set in the top-level dependencyResolutionManagement block of a + /// settings file (Groovy and Kotlin spell it alike: repositoriesMode.set(...) or + /// repositoriesMode = ...), or when the block or the + /// setting is missing. + /// + public static RepositoriesMode GetRepositoriesMode(string settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + var lines = SplitLines(settings, out _); + int open = FindTopLevelBlockOpen(lines, "dependencyResolutionManagement"); + if (open < 0) + { + return RepositoriesMode.Unspecified; + } + + int close = FindBlockClose(lines, open); + var inBlockComment = new LexState(); + for (int i = open; i <= close; i++) + { + string code = StripComments(lines[i], ref inBlockComment); + int at = code.IndexOf("repositoriesMode", StringComparison.Ordinal); + if (at < 0) + { + continue; + } + + if (code.IndexOf("PREFER_SETTINGS", at, StringComparison.Ordinal) >= 0) + { + return RepositoriesMode.PreferSettings; + } + + if (code.IndexOf("PREFER_PROJECT", at, StringComparison.Ordinal) >= 0) + { + return RepositoriesMode.PreferProject; + } + + if (code.IndexOf("FAIL_ON_PROJECT_REPOS", at, StringComparison.Ordinal) >= 0) + { + return RepositoriesMode.FailOnProjectRepos; + } + } + + return RepositoriesMode.Unspecified; + } + + /// + /// Where mavenCentral() has to be declared so a Maven dependency resolves, given the project's + /// settings and root build files (either DSL; pass null for a missing file). The settings + /// dependencyResolutionManagement repositories are used when that block declares any; with + /// PREFER_PROJECT (also Gradle's default when no mode is set) the root allprojects + /// repositories are kept in step too, as they take precedence there. Without a settings repositories + /// block the root allprojects block is the target, except under FAIL_ON_PROJECT_REPOS, + /// where project repositories are not allowed to exist, so nothing is added there. + /// + public static ProjectRepositoriesResult EnsureProjectRepositories(string settings, string rootBuild) + { + var result = new ProjectRepositoriesResult + { + Settings = settings, + RootBuild = rootBuild, + Mode = settings == null ? RepositoriesMode.Unspecified : GetRepositoriesMode(settings), + }; + + RepositoryStatus inSettings = RepositoryStatus.BlockNotFound; + if (settings != null) + { + inSettings = EnsureMavenCentral(settings, "dependencyResolutionManagement", out result.Settings); + } + + if (inSettings == RepositoryStatus.BlockNotFound && + (result.Mode == RepositoriesMode.PreferSettings || result.Mode == RepositoriesMode.FailOnProjectRepos) && + TryAddRepositoriesBlock(settings, "dependencyResolutionManagement", out string withBlock)) + { + // Project repositories are ignored (PREFER_SETTINGS) or rejected (FAIL_ON_PROJECT_REPOS) in these + // modes, so with no settings repositories block the dependency can only resolve through a new one. + result.Settings = withBlock; + inSettings = RepositoryStatus.Inserted; + } + + bool settingsDeclares = inSettings != RepositoryStatus.BlockNotFound; + bool rootWins = !settingsDeclares || + result.Mode == RepositoriesMode.PreferProject || + result.Mode == RepositoriesMode.Unspecified; + RepositoryStatus inRoot = RepositoryStatus.BlockNotFound; + if (rootBuild != null && rootWins && result.Mode != RepositoriesMode.FailOnProjectRepos) + { + inRoot = EnsureMavenCentral(rootBuild, "allprojects", out result.RootBuild); + } + + result.SettingsStatus = inSettings; + result.RootBuildStatus = inRoot; + result.Found = settingsDeclares || inRoot != RepositoryStatus.BlockNotFound; + return result; + } + + static bool TryAddRepositoriesBlock(string gradle, string outerBlock, out string updated) + { + updated = gradle; + var lines = SplitLines(gradle, out string newline); + int outerOpen = FindTopLevelBlockOpen(lines, outerBlock); + if (outerOpen < 0 || IsSingleLineBlock(lines[outerOpen])) + { + return false; + } + + int outerClose = FindBlockClose(lines, outerOpen); + string indent = LeadingWhitespace(lines[outerOpen]) + " "; + lines.InsertRange(outerClose, new[] + { + indent + "repositories {", + indent + " mavenCentral() " + RepositoryMarker, + indent + "}", + }); + updated = JoinLines(lines, newline, gradle.EndsWith("\n", StringComparison.Ordinal)); + return true; + } + + public sealed class ProjectRepositoriesResult + { + public string Settings; + public string RootBuild; + public RepositoriesMode Mode; + public RepositoryStatus SettingsStatus; + public RepositoryStatus RootBuildStatus; + + /// False when no repositories block was found to hold (or already hold) mavenCentral(). + public bool Found; + } + + static List BuildRegion(string indent, string group, IList artifacts, string version, GradleDsl dsl) { var region = new List { indent + DependenciesBeginMarker + " (com.bugsee.unity)" }; for (int i = 0; i < artifacts.Count; i++) { - region.Add(indent + "implementation '" + group + ":" + artifacts[i] + ":" + version + "'"); + string coordinate = group + ":" + artifacts[i] + ":" + version; + region.Add(dsl == GradleDsl.Kotlin + ? indent + "implementation(\"" + coordinate + "\")" + : indent + "implementation '" + coordinate + "'"); } region.Add(indent + DependenciesEndMarker); @@ -255,7 +434,7 @@ sealed class Declaration static List FindProjectDeclarations(List lines, int skipStart, int skipEnd) { var found = new List(); - bool inBlockComment = false; + var inBlockComment = new LexState(); for (int i = 0; i < lines.Count; i++) { string code = StripComments(lines[i], ref inBlockComment); @@ -280,7 +459,7 @@ static List FindProjectDeclarations(List lines, int skipSta } var declaration = Classify(code, at); - if (declaration != null) + if (declaration != null && !IsOtherConfiguration(code.Substring(0, at))) { declaration.Line = lines[i].Trim(); found.Add(declaration); @@ -291,6 +470,74 @@ static List FindProjectDeclarations(List lines, int skipSta return found; } + /// + /// True when the call or configuration the coordinate belongs to is one that does not put the SDK on the + /// app's runtime classpath: exclude, compileOnly*, test configurations (testImplementation, + /// androidTestImplementation, ...) and annotation-processor configurations. + /// is the code of the line up to the coordinate. A coordinate with no call before it (a continuation line) + /// counts as a declaration. + /// + static bool IsOtherConfiguration(string before) + { + // Drop string contents (they hold group names such as "com.bugsee") and the keys of map notation. + var sb = new StringBuilder(); + char quote = '\0'; + foreach (char c in before) + { + if (quote != '\0') + { + if (c == quote) + { + quote = '\0'; + } + + continue; + } + + if (c == '\'' || c == '"') + { + quote = c; + sb.Append(' '); + continue; + } + + sb.Append(c); + } + + string name = null; + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(sb.ToString(), @"[A-Za-z_][A-Za-z0-9_]*")) + { + switch (m.Value) + { + case "group": + case "name": + case "module": + case "version": + case "ext": + case "classifier": + continue; + } + + name = m.Value; + } + + if (name == null) + { + return false; + } + + string lower = name.ToLowerInvariant(); + return lower == "exclude" || + lower.Contains("compileonly") || + lower.StartsWith("test", StringComparison.Ordinal) || + lower.StartsWith("androidtest", StringComparison.Ordinal) || + lower.StartsWith("unittest", StringComparison.Ordinal) || + lower.Contains("annotationprocessor") || + lower.StartsWith("kapt", StringComparison.Ordinal) || + lower.StartsWith("ksp", StringComparison.Ordinal); + } + /// /// bugsee-android or bugsee-android-ndk, as a Maven coordinate /// (com.bugsee:bugsee-android:7.4.1), a map notation (name: 'bugsee-android') or a local @@ -388,9 +635,9 @@ static void ReportProjectDeclarations(DependencyResult result, List static bool DeclaresMavenCentral(string line) { - bool inBlockComment = false; + var inBlockComment = new LexState(); string code = StripComments(line, ref inBlockComment); - return code.IndexOf("mavenCentral()", StringComparison.Ordinal) >= 0 || + return System.Text.RegularExpressions.Regex.IsMatch(code, @"\bmavenCentral\s*[({]") || code.IndexOf("repo.maven.apache.org/maven2", StringComparison.Ordinal) >= 0 || code.IndexOf("repo1.maven.org/maven2", StringComparison.Ordinal) >= 0; } @@ -399,7 +646,7 @@ static bool DeclaresMavenCentral(string line) static int FindTopLevelBlockOpen(List lines, string name) { int depth = 0; - bool inBlockComment = false; + var inBlockComment = new LexState(); for (int i = 0; i < lines.Count; i++) { bool wasInComment = inBlockComment; @@ -418,7 +665,7 @@ static int FindTopLevelBlockOpen(List lines, string name) /// Index of the line opening name { directly inside the block opened at . static int FindChildBlockOpen(List lines, int open, int close, string name) { - bool inBlockComment = false; + var inBlockComment = new LexState(); int depth = BraceDelta(StripComments(lines[open], ref inBlockComment)); for (int i = open + 1; i < close; i++) { @@ -439,7 +686,7 @@ static int FindChildBlockOpen(List lines, int open, int close, string na static int FindBlockClose(List lines, int open) { int depth = 0; - bool inBlockComment = false; + var inBlockComment = new LexState(); for (int i = open; i < lines.Count; i++) { depth += BraceDelta(StripComments(lines[i], ref inBlockComment)); @@ -466,7 +713,7 @@ static bool OpensBlock(string code, string name) static bool IsSingleLineBlock(string line) { - bool inBlockComment = false; + var inBlockComment = new LexState(); string code = StripComments(line, ref inBlockComment); int open = code.IndexOf('{'); return open >= 0 && code.IndexOf('}', open) >= 0; @@ -494,18 +741,29 @@ static int BraceDelta(string code) /// The code part of a line: comments removed, string literal contents kept (they hold the /// coordinates) but their braces blanked so they do not count as structure. /// - static string StripComments(string line, ref bool inBlockComment) + static string StripComments(string line, ref LexState inBlockComment) { var sb = new StringBuilder(line.Length); char quote = '\0'; for (int i = 0; i < line.Length; i++) { char c = line[i]; - if (inBlockComment) + if (inBlockComment.RawString) + { + if (c == '"' && i + 2 < line.Length && line[i + 1] == '"' && line[i + 2] == '"') + { + inBlockComment.RawString = false; + i += 2; + } + + continue; + } + + if (inBlockComment.Block) { if (c == '*' && i + 1 < line.Length && line[i + 1] == '/') { - inBlockComment = false; + inBlockComment.Block = false; i++; } @@ -537,11 +795,19 @@ static string StripComments(string line, ref bool inBlockComment) if (c == '/' && i + 1 < line.Length && line[i + 1] == '*') { - inBlockComment = true; + inBlockComment.Block = true; i++; continue; } + // Kotlin / Groovy triple-quoted strings can span lines: their content is dropped like a comment. + if (quote == '\0' && c == '"' && i + 2 < line.Length && line[i + 1] == '"' && line[i + 2] == '"') + { + inBlockComment.RawString = true; + i += 2; + continue; + } + if (c == '\'' || c == '"') { quote = c; @@ -553,6 +819,18 @@ static string StripComments(string line, ref bool inBlockComment) return sb.ToString(); } + /// Lexer state carried across lines: inside a block comment or a triple-quoted string. + struct LexState + { + public bool Block; + public bool RawString; + + public static implicit operator bool(LexState state) + { + return state.Block || state.RawString; + } + } + static bool IsArtifactChar(char c) { return char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.'; diff --git a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs new file mode 100644 index 0000000..e0224d6 --- /dev/null +++ b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs @@ -0,0 +1,421 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// Kotlin-DSL and repositories-mode handling of . + /// + /// Fixture provenance: the 2021.3 settings fixture is the settingsTemplate.gradle shipped in Unity + /// 2021.3.45f1 (PlaybackEngines/AndroidPlayer/Tools/GradleTemplates) with its placeholders emptied and mavenCentral() dropped from the project repositories (so there is something to insert). The + /// Kotlin, 2022 and Unity 6 fixtures are RECONSTRUCTED (no 2022/6 editor was available): Unity 2022.2+ and + /// Unity 6 keep the same Groovy settings shape (vendor docs show PREFER_SETTINGS + google/mavenCentral/flatDir), + /// and Unity generates no .kts files itself, so the .kts fixtures model a project that converted them. + /// + public class GradleKotlinDslTests + { + const string Group = "com.bugsee"; + const string Version = "7.4.1"; + static readonly string[] Artifacts = { "bugsee-android", "bugsee-android-ndk" }; + + const string KtsCore = " implementation(\"com.bugsee:bugsee-android:7.4.1\")"; + const string KtsNdk = " implementation(\"com.bugsee:bugsee-android-ndk:7.4.1\")"; + + // 2021.3.45 settingsTemplate.gradle, placeholders emptied, project mavenCentral() removed. + const string Unity2021Settings = + "pluginManagement {\n" + + " repositories {\n" + + " \n" + + " gradlePluginPortal()\n" + + " google()\n" + + " mavenCentral()\n" + + " }\n" + + "}\n" + + "\n" + + "include ':launcher', ':unityLibrary'\n" + + "\n" + + "\n" + + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)\n" + + " repositories {\n" + + " \n" + + " google()\n" + + " flatDir {\n" + + " dirs \"${project(':unityLibrary').projectDir}/libs\"\n" + + " }\n" + + " }\n" + + "}\n"; + + // RECONSTRUCTED, not generated by Unity 6: the 2021.3.45 baseProjectTemplate.gradle with the AGP version + // changed. Whether Unity 6's template differs beyond that could not be confirmed (docs describe the files' + // roles, not their contents). + const string ReconstructedRootBuild = + "plugins {\n" + + " id 'com.android.application' version '8.3.0' apply false\n" + + " id 'com.android.library' version '8.3.0' apply false\n" + + "}\n" + + "\n" + + "task clean(type: Delete) {\n" + + " delete rootProject.buildDir\n" + + "}\n"; + + const string KtsLibrary = + "plugins {\n" + + " id(\"com.android.library\")\n" + + "}\n" + + "\n" + + "dependencies {\n" + + " implementation(fileTree(mapOf(\"dir\" to \"libs\", \"include\" to listOf(\"*.jar\"))))\n" + + "}\n" + + "\n" + + "android {\n" + + " namespace = \"com.unity3d.player\"\n" + + "}\n"; + + const string KtsSettings = + "pluginManagement {\n" + + " repositories {\n" + + " gradlePluginPortal()\n" + + " google()\n" + + " }\n" + + "}\n" + + "\n" + + "include(\":launcher\", \":unityLibrary\")\n" + + "\n" + + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n" + + " repositories {\n" + + " google()\n" + + " flatDir {\n" + + " dirs(\"${project(\":unityLibrary\").projectDir}/libs\")\n" + + " }\n" + + " }\n" + + "}\n"; + + static GradleMavenInjection.DependencyResult ApplyKts(string gradle) + { + return GradleMavenInjection.ApplyDependencies( + gradle, Group, Artifacts, Version, GradleMavenInjection.GradleDsl.Kotlin); + } + + [Test] + public void Dsl_is_chosen_by_file_name() + { + Assert.That(GradleMavenInjection.DslForFile("/p/unityLibrary/build.gradle.kts"), + Is.EqualTo(GradleMavenInjection.GradleDsl.Kotlin)); + Assert.That(GradleMavenInjection.DslForFile("/p/unityLibrary/build.gradle"), + Is.EqualTo(GradleMavenInjection.GradleDsl.Groovy)); + } + + [Test] + public void Kotlin_library_gets_function_call_declarations_and_is_idempotent() + { + var first = ApplyKts(KtsLibrary); + + Assert.That(first.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(first.Text, Does.Contain(KtsCore + "\n" + KtsNdk + "\n")); + Assert.That(first.Text, Does.Not.Contain("implementation 'com.bugsee")); + Assert.That(first.Text.IndexOf("dependencies {"), Is.LessThan(first.Text.IndexOf(KtsCore))); + Assert.That(first.Text.IndexOf(KtsCore), Is.LessThan(first.Text.IndexOf("android {"))); + + var second = ApplyKts(first.Text); + Assert.That(second.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.AlreadyInjected)); + Assert.That(second.Text, Is.EqualTo(first.Text)); + } + + [Test] + public void Kotlin_project_declarations_are_recognised_and_kept() + { + string gradle = KtsLibrary.Replace( + "dependencies {\n", + "dependencies {\n implementation(\"com.bugsee:bugsee-android:7.3.0\")\n implementation(\"com.bugsee:bugsee-android-ndk:7.3.0\")\n"); + var result = ApplyKts(gradle); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Text, Is.EqualTo(gradle)); + Assert.That(result.Warnings, Has.Count.EqualTo(2)); + } + + [Test] + public void Version_catalog_alias_is_not_seen_as_a_declaration_and_is_not_duplicated() + { + // libs.bugsee.android carries no coordinate text, so the injector cannot know; it adds the pinned one. + string gradle = KtsLibrary.Replace("dependencies {\n", "dependencies {\n implementation(libs.bugsee.android)\n"); + var result = ApplyKts(gradle); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + } + + [Test] + public void Kotlin_file_without_a_dependencies_block_gets_one() + { + string gradle = "plugins {\n id(\"com.android.library\")\n}\n"; + var result = ApplyKts(gradle); + + Assert.That(result.Text, Does.EndWith("dependencies {\n" + + " // bugsee:maven-dependencies begin (com.bugsee.unity)\n" + + KtsCore + "\n" + KtsNdk + "\n" + + " // bugsee:maven-dependencies end\n}\n")); + } + + [Test] + public void Groovy_overload_keeps_the_groovy_syntax() + { + var result = GradleMavenInjection.ApplyDependencies( + "dependencies {\n}\n", Group, Artifacts, Version); + + Assert.That(result.Text, Does.Contain("implementation 'com.bugsee:bugsee-android:7.4.1'")); + } + + [Test] + public void Repositories_mode_is_read_from_the_settings_block() + { + Assert.That(GradleMavenInjection.GetRepositoriesMode(Unity2021Settings), + Is.EqualTo(GradleMavenInjection.RepositoriesMode.PreferSettings)); + Assert.That(GradleMavenInjection.GetRepositoriesMode(KtsSettings), + Is.EqualTo(GradleMavenInjection.RepositoriesMode.FailOnProjectRepos)); + Assert.That(GradleMavenInjection.GetRepositoriesMode( + "dependencyResolutionManagement {\n repositoriesMode = RepositoriesMode.PREFER_PROJECT\n}\n"), + Is.EqualTo(GradleMavenInjection.RepositoriesMode.PreferProject)); + Assert.That(GradleMavenInjection.GetRepositoriesMode( + "dependencyResolutionManagement {\n // repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n}\n"), + Is.EqualTo(GradleMavenInjection.RepositoriesMode.Unspecified)); + Assert.That(GradleMavenInjection.GetRepositoriesMode("include ':a'\n"), + Is.EqualTo(GradleMavenInjection.RepositoriesMode.Unspecified)); + } + + [Test] + public void Unity_2021_settings_without_maven_central_get_it_in_settings_only() + { + var result = GradleMavenInjection.EnsureProjectRepositories(Unity2021Settings, ReconstructedRootBuild); + + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.RootBuildStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.BlockNotFound)); + Assert.That(result.Found, Is.True); + Assert.That(result.RootBuild, Is.EqualTo(ReconstructedRootBuild)); + + var again = GradleMavenInjection.EnsureProjectRepositories(result.Settings, result.RootBuild); + Assert.That(again.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(again.Settings, Is.EqualTo(result.Settings)); + } + + [Test] + public void Kotlin_settings_with_fail_on_project_repos_get_it_in_settings_and_never_in_the_root() + { + const string rootKts = + "allprojects {\n" + + " repositories {\n" + + " google()\n" + + " }\n" + + "}\n"; + var result = GradleMavenInjection.EnsureProjectRepositories(KtsSettings, rootKts); + + Assert.That(result.Mode, Is.EqualTo(GradleMavenInjection.RepositoriesMode.FailOnProjectRepos)); + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.Settings, Does.Contain(" repositories {\n mavenCentral() // bugsee:maven-repository\n google()")); + Assert.That(result.RootBuild, Is.EqualTo(rootKts)); + } + + [Test] + public void Fail_on_project_repos_without_settings_repositories_is_never_given_root_repositories() + { + const string settings = + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n" + + "}\n"; + var result = GradleMavenInjection.EnsureProjectRepositories( + settings, "allprojects {\n repositories {\n google()\n }\n}\n"); + + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.RootBuildStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.BlockNotFound)); + } + + [Test] + public void Prefer_project_keeps_the_root_allprojects_repositories_in_step() + { + string settings = Unity2021Settings.Replace("PREFER_SETTINGS", "PREFER_PROJECT"); + const string root = + "allprojects {\n" + + " repositories {\n" + + " google()\n" + + " }\n" + + "}\n"; + var result = GradleMavenInjection.EnsureProjectRepositories(settings, root); + + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.RootBuildStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.RootBuild, Does.Contain("mavenCentral() // bugsee:maven-repository")); + } + + [Test] + public void Settings_without_the_block_fall_back_to_the_root_allprojects() + { + const string root = + "allprojects {\n" + + " repositories {\n" + + " google()\n" + + " }\n" + + "}\n"; + var result = GradleMavenInjection.EnsureProjectRepositories("include ':launcher'\n", root); + + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.BlockNotFound)); + Assert.That(result.RootBuildStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.Found, Is.True); + } + + [Test] + public void Missing_files_are_reported_as_not_found() + { + var result = GradleMavenInjection.EnsureProjectRepositories(null, null); + + Assert.That(result.Found, Is.False); + } + + static GradleMavenInjection.DependencyResult ApplyGroovy(string gradle) + { + return GradleMavenInjection.ApplyDependencies(gradle, Group, Artifacts, Version); + } + + [TestCase(" compileOnly 'com.bugsee:bugsee-android:7.4.1'")] + [TestCase(" testImplementation 'com.bugsee:bugsee-android:7.4.1'")] + [TestCase(" androidTestImplementation 'com.bugsee:bugsee-android-ndk:7.4.1'")] + [TestCase(" implementation('com.example:lib:1') { exclude group: 'com.bugsee', module: 'bugsee-android' }")] + [TestCase(" implementation('com.example:lib:1') {\n exclude group: 'com.bugsee', module: 'bugsee-android'\n }")] + public void Groovy_other_configurations_and_exclusions_are_not_project_declarations(string line) + { + var result = ApplyGroovy("dependencies {\n" + line + "\n}\n"); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Warnings, Is.Empty); + } + + [TestCase(" compileOnly(\"com.bugsee:bugsee-android:7.4.1\")")] + [TestCase(" testImplementation(\"com.bugsee:bugsee-android:7.4.1\")")] + [TestCase(" androidTestImplementation(\"com.bugsee:bugsee-android-ndk:7.4.1\")")] + [TestCase(" implementation(\"com.example:lib:1\") { exclude(group = \"com.bugsee\", module = \"bugsee-android\") }")] + [TestCase(" implementation(\"com.example:lib:1\") {\n exclude(module = \"bugsee-android\")\n }")] + public void Kotlin_other_configurations_and_exclusions_are_not_project_declarations(string line) + { + var result = ApplyKts("dependencies {\n" + line + "\n}\n"); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Warnings, Is.Empty); + } + + [TestCase(" implementation 'com.bugsee:bugsee-android:7.3.0'")] + [TestCase(" api 'com.bugsee:bugsee-android:7.3.0'")] + [TestCase(" runtimeOnly 'com.bugsee:bugsee-android:7.3.0'")] + [TestCase(" implementation(name: 'bugsee-android', ext: 'aar')")] + [TestCase(" releaseImplementation \"com.bugsee:bugsee-android:7.3.0\"")] + public void Runtime_classpath_declarations_still_count(string line) + { + var result = ApplyGroovy("dependencies {\n" + line + "\n}\n"); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + } + + [Test] + public void Kotlin_api_declaration_counts() + { + var result = ApplyKts("dependencies {\n api(\"com.bugsee:bugsee-android:7.3.0\")\n}\n"); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + } + + [Test] + public void Prefer_settings_without_a_settings_repositories_block_gets_one_created() + { + const string settings = + "include ':launcher', ':unityLibrary'\n" + + "\n" + + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)\n" + + "}\n"; + const string root = + "allprojects {\n" + + " repositories {\n" + + " google()\n" + + " }\n" + + "}\n"; + var result = GradleMavenInjection.EnsureProjectRepositories(settings, root); + + Assert.That(result.Found, Is.True); + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(result.RootBuildStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.BlockNotFound)); + Assert.That(result.RootBuild, Is.EqualTo(root), "Gradle ignores root repositories under PREFER_SETTINGS"); + Assert.That(result.Settings, Is.EqualTo( + "include ':launcher', ':unityLibrary'\n" + + "\n" + + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)\n" + + " repositories {\n" + + " mavenCentral() // bugsee:maven-repository\n" + + " }\n" + + "}\n")); + + var again = GradleMavenInjection.EnsureProjectRepositories(result.Settings, result.RootBuild); + Assert.That(again.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(again.Settings, Is.EqualTo(result.Settings)); + } + + [Test] + public void Fail_on_project_repos_without_a_settings_repositories_block_gets_one_created() + { + const string settings = + "dependencyResolutionManagement {\n" + + " repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n" + + "}\n"; + var result = GradleMavenInjection.EnsureProjectRepositories(settings, null); + + Assert.That(result.Found, Is.True); + Assert.That(result.Settings, Does.Contain(" repositories {\n mavenCentral() // bugsee:maven-repository\n }\n}")); + } + + [Test] + public void Single_line_dependency_resolution_block_is_reported_not_found() + { + const string settings = "dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) }\n"; + var result = GradleMavenInjection.EnsureProjectRepositories(settings, null); + + Assert.That(result.Found, Is.False); + Assert.That(result.Settings, Is.EqualTo(settings)); + } + + [Test] + public void Maven_central_with_a_content_block_counts_as_declared() + { + const string root = + "allprojects {\n" + + " repositories {\n" + + " mavenCentral {\n" + + " content { includeGroup(\"com.bugsee\") }\n" + + " }\n" + + " }\n" + + "}\n"; + + Assert.That( + GradleMavenInjection.EnsureMavenCentral(root, "allprojects", out string updated), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(updated, Is.EqualTo(root)); + } + + [Test] + public void Multi_line_triple_quoted_strings_do_not_hide_or_fake_structure() + { + const string gradle = + "val notes = \"\"\"\n" + + "dependencies {\n" + + " implementation(\"com.bugsee:bugsee-android:7.0.0\")\n" + + "\"\"\"\n" + + "\n" + + "dependencies {\n" + + " implementation(fileTree(\"libs\"))\n" + + "}\n"; + var result = ApplyKts(gradle); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(result.Text, Does.Contain("dependencies {\n // bugsee:maven-dependencies begin")); + Assert.That(result.Text.IndexOf("val notes"), Is.LessThan(result.Text.IndexOf("bugsee:maven-dependencies begin"))); + Assert.That(result.Text, Does.StartWith(gradle.Substring(0, gradle.IndexOf("dependencies {\n implementation(fileTree")))); + } + } +} diff --git a/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs b/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs index 02bf77a..aec9c25 100644 --- a/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs +++ b/Tests~/WrapperPolicy/GradleMavenInjectionTests.cs @@ -380,6 +380,9 @@ public void Injector_is_a_post_generate_step_pinned_to_the_generated_version_con Assert.That(text, Does.Contain("BugseePackageVersion.AndroidMavenArtifact")); Assert.That(text, Does.Contain("BugseePackageVersion.AndroidNdkArtifact")); Assert.That(text, Does.Contain("GradleMavenInjection.ApplyDependencies(")); + Assert.That(text, Does.Contain("GradleMavenInjection.DslForFile(path)")); + Assert.That(text, Does.Contain("GradleMavenInjection.EnsureProjectRepositories(")); + Assert.That(text, Does.Contain("\".gradle.kts\"")); Assert.That(text, Does.Not.Contain("#if UNITY_ANDROID")); } From c7b6cb1d6bc9430dff70f8cfd5ab1e734ffe8b50 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 23:12:36 +0500 Subject: [PATCH 105/157] Document the Android Mono symbolication limit and warn once in the symbol upload. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unity 2021.3 has no Mono ARM64 and libmonobdwgc.so has no GNU build-id, so IL2CPP is the supported configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 4 ++ Documentation~/index.md | 2 +- Editor/BugseeAndroidMonoWarning.cs | 45 +++++++++++++ Editor/BugseeAndroidMonoWarning.cs.meta | 11 ++++ .../WrapperPolicy/AndroidMonoSymbolPolicy.cs | 38 +++++++++++ .../AndroidMonoSymbolPolicy.cs.meta | 11 ++++ .../AndroidMonoSymbolPolicyTests.cs | 65 +++++++++++++++++++ 7 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 Editor/BugseeAndroidMonoWarning.cs create mode 100644 Editor/BugseeAndroidMonoWarning.cs.meta create mode 100644 Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs create mode 100644 Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidMonoSymbolPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46394f7..721d8a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - Unreleased +### Changed + +- Android Mono builds cannot symbolicate native frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`): Unity 2021.3 has no Mono ARM64 and those libraries have no GNU build-id. IL2CPP is the supported configuration for fully symbolicated native crashes. Documented in the Player Settings checklist, and a Mono Android build now logs a one-time warning at build time regardless of symbol upload settings (the build is never failed). + ### Fixed - iOS appearance setters and getters were a no-op because `+[Bugsee getAppearance]` returns a `BugseeTheme`, not a `BGSAppearance`. They now map onto `BugseeTheme` (report/feedback colors, report placeholders). Properties with no iOS equivalent are skipped and read back null: Report `ActionBarTextColor`, `ActionBarButtonBackgroundClickedColor`, `SeverityLabelActiveColor`; Feedback `ActionBarButtonBackgroundClickedColor`, `DateTextColor`, `EmailSkipBackgroundClickedColor`, `EmailContinueClickedColor`, `InputTextHintColor`, `BottomDelimiterColor`, `LoadingBarBackgroundColor`, `LoadingTextColor`, `ErrorTextColor`, `VersionChangedBackgroundColor`, `VersionChangedTextColor`; `Notification::Title`. diff --git a/Documentation~/index.md b/Documentation~/index.md index c5312c0..7d94f12 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -112,7 +112,7 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn **Android** -1. Scripting Backend: IL2CPP +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. Custom Base / Launcher Gradle templates (Bugsee injects `ndk { enabled = true }` on Unity 6+) diff --git a/Editor/BugseeAndroidMonoWarning.cs b/Editor/BugseeAndroidMonoWarning.cs new file mode 100644 index 0000000..bd32a78 --- /dev/null +++ b/Editor/BugseeAndroidMonoWarning.cs @@ -0,0 +1,45 @@ +#if UNITY_EDITOR +using System; +using Bugsee.WrapperPolicy; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using Debug = UnityEngine.Debug; + +namespace Bugsee.Editor +{ + /// + /// Warns once per domain load when an Android build used the Mono scripting backend (see + /// ). Independent of the symbol upload settings (token, opt-out), and the + /// backend is the one recorded for this build, not the live Player Settings. Never fails the build. + /// + public sealed class BugseeAndroidMonoWarning : IPostprocessBuildWithReport + { + static bool _warned; + + public int callbackOrder => 900; + + public void OnPostprocessBuild(BuildReport report) + { + try + { + if (report == null || report.summary.platform != BuildTarget.Android) + { + return; + } + + var backend = BugseeBuildIdentity.AndroidNameSettings(report).Backend; + var warning = AndroidMonoSymbolPolicy.TakeWarning(backend, ref _warned); + if (warning != null) + { + Debug.LogWarning(warning); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: could not check the Android scripting backend: {ex.Message}"); + } + } + } +} +#endif diff --git a/Editor/BugseeAndroidMonoWarning.cs.meta b/Editor/BugseeAndroidMonoWarning.cs.meta new file mode 100644 index 0000000..6b17a0d --- /dev/null +++ b/Editor/BugseeAndroidMonoWarning.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 40e39494491b4bfe802df824f098b0f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs b/Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs new file mode 100644 index 0000000..f020f9c --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs @@ -0,0 +1,38 @@ +using System; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Android builds on the Mono scripting backend lose the native frames inside the Mono runtime + /// (libmonobdwgc-2.0.so, libmono-native.so, libMonoPosixHelper.so): Unity 2021.3 has no Mono ARM64 (ARMv7 + /// only) and those libraries carry no GNU build-id, so their frames cannot be matched to symbols. libunity.so + /// and libmain.so do have build-ids, so frames in them still symbolicate. IL2CPP is the supported configuration + /// for fully symbolicated native crashes. The Editor warns once per domain load; it never fails the build. + /// + public static class AndroidMonoSymbolPolicy + { + public const string Warning = + "Bugsee: this Android build uses the Mono scripting backend. Native frames inside the Mono runtime " + + "(libmonobdwgc-2.0.so, libmono-native.so, libMonoPosixHelper.so) cannot be symbolicated: Unity 2021.3 " + + "has no Mono ARM64 and those libraries have no GNU build-id. Use the IL2CPP scripting backend for " + + "fully symbolicated native crashes. The build continues."; + + /// The value of ScriptingImplementation.Mono2x (or any name starting with "Mono"). + public static bool IsMono(string scriptingBackend) + { + return scriptingBackend != null + && scriptingBackend.StartsWith("Mono", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns the warning text the first time it is called with a Mono backend, then null. The caller keeps + /// for the session. + /// + public static string TakeWarning(string scriptingBackend, ref bool alreadyWarned) + { + if (alreadyWarned || !IsMono(scriptingBackend)) return null; + alreadyWarned = true; + return Warning; + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs.meta b/Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs.meta new file mode 100644 index 0000000..881ef39 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidMonoSymbolPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7dc00c0a9ba74d1db3568377b6de1404 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/AndroidMonoSymbolPolicyTests.cs b/Tests~/WrapperPolicy/AndroidMonoSymbolPolicyTests.cs new file mode 100644 index 0000000..33573b8 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidMonoSymbolPolicyTests.cs @@ -0,0 +1,65 @@ +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidMonoSymbolPolicyTests + { + [TestCase("Mono2x", true)] + [TestCase("mono2x", true)] + [TestCase("IL2CPP", false)] + [TestCase("", false)] + [TestCase(null, false)] + public void Detects_the_mono_backend(string backend, bool expected) + { + Assert.That(AndroidMonoSymbolPolicy.IsMono(backend), Is.EqualTo(expected)); + } + + [Test] + public void Warns_once_for_mono() + { + bool warned = false; + Assert.That(AndroidMonoSymbolPolicy.TakeWarning("Mono2x", ref warned), Does.Contain("IL2CPP")); + Assert.That(warned, Is.True); + Assert.That(AndroidMonoSymbolPolicy.TakeWarning("Mono2x", ref warned), Is.Null); + } + + [Test] + public void Never_warns_for_il2cpp_and_leaves_the_flag_alone() + { + bool warned = false; + Assert.That(AndroidMonoSymbolPolicy.TakeWarning("IL2CPP", ref warned), Is.Null); + Assert.That(warned, Is.False); + } + + [Test] + public void Warning_names_the_real_libraries_and_does_not_overclaim() + { + Assert.That(AndroidMonoSymbolPolicy.Warning, Does.Contain("libmonobdwgc-2.0.so")); + Assert.That(AndroidMonoSymbolPolicy.Warning, Does.Not.Contain("libmonobdwgc.so")); + Assert.That(AndroidMonoSymbolPolicy.Warning, Does.Contain("inside the Mono runtime")); + } + + [Test] + public void Uses_the_backend_recorded_for_the_build() + { + var recorded = new PlayerNameSettings("out.apk", "1.0", 1, "Mono2x"); + bool warned = false; + Assert.That(AndroidMonoSymbolPolicy.TakeWarning(recorded.Backend, ref warned), Is.Not.Null); + } + + // The build-time warning must not depend on the symbol upload configuration, and must use the build's own + // recorded settings rather than the live Player Settings. + [Test] + public void Editor_warning_is_independent_of_upload_config_and_uses_recorded_settings() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!File.Exists(Path.Combine(dir, "package.json"))) dir = Directory.GetParent(dir).FullName; + var text = File.ReadAllText(Path.Combine(dir, "Editor", "BugseeAndroidMonoWarning.cs")); + Assert.That(text, Does.Contain("BugseeBuildIdentity.AndroidNameSettings(report).Backend")); + Assert.That(text, Does.Not.Contain("HasAppToken")); + Assert.That(text, Does.Not.Contain("BUGSEE_NO_SYMBOL_UPLOAD")); + Assert.That(text, Does.Not.Contain("GetScriptingBackend")); + } + } +} From 7935fb046a93c6609f6be91bec751ca541963ff0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 23:08:37 +0500 Subject: [PATCH 106/157] iOS bridge: forward LogException options; use JsonWire in native callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogException options (labels, includeVideo, mergingRules) now reach BugseeExceptionLoggingOptions instead of being dropped. IosNativeCallbacks drops its private JsonString encoder for the tested JsonWire.String. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + Plugins/iOS/BugseeUnityBridge.mm | 58 ++++++++++++++++++- Runtime/Platform/IOS/IOSBridge.cs | 8 ++- Runtime/Platform/IOS/IosNativeCallbacks.cs | 9 +-- Runtime/WrapperPolicy/OptionPlatformGate.cs | 9 +++ .../IosRegistrationSourceTests.cs | 18 ++++++ .../WrapperPolicy/OptionPlatformGateTests.cs | 6 ++ Tests~/iOS/BridgeTests.mm | 19 ++++++ 8 files changed, 115 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421399c..109caf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. +- iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. - iOS appearance setters and getters were a no-op because `+[Bugsee getAppearance]` returns a `BugseeTheme`, not a `BGSAppearance`. They now map onto `BugseeTheme` (report/feedback colors, report placeholders). Properties with no iOS equivalent are skipped and read back null: Report `ActionBarTextColor`, `ActionBarButtonBackgroundClickedColor`, `SeverityLabelActiveColor`; Feedback `ActionBarButtonBackgroundClickedColor`, `DateTextColor`, `EmailSkipBackgroundClickedColor`, `EmailContinueClickedColor`, `InputTextHintColor`, `BottomDelimiterColor`, `LoadingBarBackgroundColor`, `LoadingTextColor`, `ErrorTextColor`, `VersionChangedBackgroundColor`, `VersionChangedTextColor`; `Notification::Title`. ### Added diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 7b95b81..d2b4fb5 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -303,17 +303,69 @@ void _bugsee_event(const char *name, const char *paramsJson) [Bugsee event:BugseeNSString(name)]; } -void _bugsee_logException(const char *name, const char *reason, bool handled) +// Honoured keys: labels (strings), includeVideo (bool, default true), mergingRules (map, the SDK's own keys) and +// skipFrames (number, as on Android). The domain is owned by the wrapper (the exception name), so a caller-supplied one is ignored, as on Android. +static BugseeExceptionLoggingOptions *BugseeExceptionOptionsFrom(const char *optionsJson) +{ + id parsed = BugseeDeserializeJson(optionsJson); + if (![parsed isKindOfClass:[NSDictionary class]]) { + return nil; + } + NSDictionary *d = parsed; + BugseeExceptionLoggingOptions *options = [BugseeExceptionLoggingOptions new]; + // A fresh options object has includeVideo off; nil options keep the video, so only an explicit false drops it. + options.includeVideo = YES; + id labels = d[@"labels"]; + if ([labels isKindOfClass:[NSArray class]]) { + NSMutableArray *clean = [NSMutableArray array]; + for (id label in (NSArray *)labels) { + if ([label isKindOfClass:[NSString class]]) { + [clean addObject:label]; + } + } + options.labels = clean; + } + id includeVideo = d[@"includeVideo"]; + if ([includeVideo isKindOfClass:[NSNumber class]]) { + options.includeVideo = [includeVideo boolValue]; + } + id rules = d[@"mergingRules"]; + NSMutableDictionary *mergingRules = [rules isKindOfClass:[NSDictionary class]] + ? [rules mutableCopy] : [NSMutableDictionary dictionary]; + // Android's top-level skipFrames is the SDK's skip-frames merging rule here. + id skipFrames = d[@"skipFrames"]; + if ([skipFrames isKindOfClass:[NSNumber class]]) { + mergingRules[BugseeMergingRulesSkipFramesKey] = skipFrames; + } + if (mergingRules.count > 0) { + options.mergingRules = mergingRules; + } + return options; +} + +void _bugsee_logException(const char *name, const char *reason, bool handled, const char *optionsJson) { NSString *finalName = BugseeNSString(name) ?: @"UnityManagedException"; NSString *finalReason = BugseeNSString(reason) ?: @""; if (handled) { - [Bugsee logException:finalName reason:finalReason options:nil completion:nil]; + [Bugsee logException:finalName reason:finalReason options:BugseeExceptionOptionsFrom(optionsJson) completion:nil]; } else { + // The SDK has no options on the unhandled path. [Bugsee logUnhandledException:finalName reason:finalReason completion:nil]; } } +#if BUGSEE_UNITY_TESTS +const char *_bugsee_test_exception_options(const char *optionsJson) +{ + BugseeExceptionLoggingOptions *o = BugseeExceptionOptionsFrom(optionsJson); + if (!o) return NULL; + NSString *summary = [NSString stringWithFormat:@"labels=%@;video=%d;rules=%@", + [o.labels componentsJoinedByString:@","], o.includeVideo ? 1 : 0, o.mergingRules]; + return strdup(summary.UTF8String); +} +#endif + void _bugsee_test_crash(void) { [Bugsee testCrash]; @@ -747,7 +799,7 @@ void _bugsee_end_blackout(void) {} void _bugsee_log(const char *message, int level) { (void)message; (void)level; } void _bugsee_trace(const char *name, const char *valueJson) { (void)name; (void)valueJson; } void _bugsee_event(const char *name, const char *paramsJson) { (void)name; (void)paramsJson; } -void _bugsee_logException(const char *name, const char *reason, bool handled) { (void)name; (void)reason; (void)handled; } +void _bugsee_logException(const char *name, const char *reason, bool handled, const char *optionsJson) { (void)name; (void)reason; (void)handled; (void)optionsJson; } void _bugsee_test_crash(void) {} void _bugsee_show_report(const char *summary, const char *description, int severity, const char *labelsJson) { (void)summary; (void)description; (void)severity; (void)labelsJson; } void _bugsee_upload(const char *summary, const char *description, int severity, const char *labelsJson) { (void)summary; (void)description; (void)severity; (void)labelsJson; } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index b7398fb..316313c 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -637,7 +637,8 @@ public void LogExceptionPayload( IDictionary options = null) { if (payload == null) return; - _bugsee_logException("UnityManagedException", ManagedExceptionPayload.ToJson(payload), true); + _bugsee_logException("UnityManagedException", ManagedExceptionPayload.ToJson(payload), true, + ToJsonObject(options)); } public void LogUnhandledExceptionPayload( @@ -645,7 +646,8 @@ public void LogUnhandledExceptionPayload( IDictionary options = null) { if (payload == null) return; - _bugsee_logException("UnityManagedException", ManagedExceptionPayload.ToJson(payload), false); + _bugsee_logException("UnityManagedException", ManagedExceptionPayload.ToJson(payload), false, + ToJsonObject(options)); } public void TestCrash() => _bugsee_test_crash(); @@ -857,7 +859,7 @@ static IDictionary WithWrapperInfo(IDictionary o ManagedReportUploadNativeCallback callback); [DllImport("__Internal")] static extern void _bugsee_trace(string name, string valueJson); [DllImport("__Internal")] static extern void _bugsee_event(string name, string paramsJson); - [DllImport("__Internal")] static extern void _bugsee_logException(string name, string reason, bool handled); + [DllImport("__Internal")] static extern void _bugsee_logException(string name, string reason, bool handled, string optionsJson); [DllImport("__Internal")] static extern void _bugsee_test_crash(); [DllImport("__Internal")] static extern void _bugsee_show_report(string summary, string description, int severity, string labelsJson); [DllImport("__Internal")] static extern void _bugsee_upload(string summary, string description, int severity, string labelsJson); diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index f1622cb..c42a41b 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -82,7 +82,7 @@ static string WrapperContextJson(Dictionary map) continue; } - parts.Add(JsonString(kv.Key) + ":" + JsonString(kv.Value.ToString())); + parts.Add(JsonWire.String(kv.Key) + ":" + JsonWire.String(kv.Value.ToString())); } return "{" + string.Join(",", parts.ToArray()) + "}"; @@ -385,7 +385,7 @@ static string ArrayToObject(string arrayJson) var v = ExtractStringField(obj, "value"); if (k != null) { - parts.Add(JsonString(k) + ":" + (v == null ? "null" : JsonString(v))); + parts.Add(JsonWire.String(k) + ":" + (v == null ? "null" : JsonWire.String(v))); } i = objEnd + 1; } @@ -445,11 +445,6 @@ static string ExtractStringField(string obj, string field) .Replace("\\r", "\r"); } - static string JsonString(string s) - { - return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } - [Serializable] sealed class StringBox { diff --git a/Runtime/WrapperPolicy/OptionPlatformGate.cs b/Runtime/WrapperPolicy/OptionPlatformGate.cs index 3e4cab7..8c90caf 100644 --- a/Runtime/WrapperPolicy/OptionPlatformGate.cs +++ b/Runtime/WrapperPolicy/OptionPlatformGate.cs @@ -3,6 +3,9 @@ namespace Bugsee.WrapperPolicy { + /// + /// Options the iOS SDK has no counterpart for (absent from its headers and binary as of 7.0.0-beta6). + /// public static class OptionPlatformGate { static readonly HashSet AndroidOnlyKeys = BuildAndroidOnlyKeys(); @@ -27,6 +30,12 @@ static HashSet BuildAndroidOnlyKeys() Options.DetectAndReportExitUnknown, Options.ReportingTriggerByNotification, Options.ReportingTriggerByBroadcast, + Options.CaptureLogsUseAllSources, + Options.CaptureRespectFlagSecure, + Options.CaptureVideoMode, + Options.CaptureVideoUsingCustomMuxer, + Options.ReportProcessingInProcess, + Options.WebViewDomainAllowlist, }; } diff --git a/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs b/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs index fa0ddc8..94c1e7c 100644 --- a/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs +++ b/Tests~/WrapperPolicy/IosRegistrationSourceTests.cs @@ -44,5 +44,23 @@ public void Wrapper_is_created_in_load_and_ensure_does_not_allocate() Assert.That(body.IndexOf("if (!gWrapper)"), Is.LessThan(body.IndexOf("BugseeUnityWrapper new"))); } } + + [Test] + public void Native_callbacks_encode_json_with_the_tested_JsonWire() + { + string text = File.ReadAllText(Path.Combine(FindRepoRoot(), "Runtime", "Platform", "IOS", "IosNativeCallbacks.cs")); + Assert.That(text, Does.Not.Contain("static string JsonString(")); + Assert.That(text, Does.Contain("JsonWire.String(")); + } + + [Test] + public void LogException_options_cross_to_the_native_bridge() + { + string text = File.ReadAllText(Path.Combine(FindRepoRoot(), "Runtime", "Platform", "IOS", "IOSBridge.cs")); + Assert.That(text, Does.Contain("static extern void _bugsee_logException(string name, string reason, bool handled, string optionsJson);")); + Assert.That(text, Does.Contain("ManagedExceptionPayload.ToJson(payload), true,\r\n") .Or.Contain("ManagedExceptionPayload.ToJson(payload), true,\n")); + Assert.That(text, Does.Contain("ManagedExceptionPayload.ToJson(payload), false,\r\n").Or.Contain("ManagedExceptionPayload.ToJson(payload), false,\n")); + Assert.That(System.Text.RegularExpressions.Regex.Matches(text, @"ToJsonObject\(options\)\);").Count, Is.EqualTo(2)); + } } } diff --git a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs index b293107..d2b8eff 100644 --- a/Tests~/WrapperPolicy/OptionPlatformGateTests.cs +++ b/Tests~/WrapperPolicy/OptionPlatformGateTests.cs @@ -47,6 +47,12 @@ static HashSet ExpectedAndroidOnlyKeysFromOptionsContract() expected.Add(Options.ReportingTriggerByNotification); expected.Add(Options.ReportingTriggerByBroadcast); + expected.Add(Options.CaptureLogsUseAllSources); + expected.Add(Options.CaptureRespectFlagSecure); + expected.Add(Options.CaptureVideoMode); + expected.Add(Options.CaptureVideoUsingCustomMuxer); + expected.Add(Options.ReportProcessingInProcess); + expected.Add(Options.WebViewDomainAllowlist); return expected; } diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index bb57429..9c25722 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -26,6 +26,7 @@ void _bugsee_stop(int token, BugseeStopCompletionFn completion); typedef void (*BugseeDeleteCompletionFn)(int token, int wiped); void _bugsee_delete_collected_data(int token, BugseeDeleteCompletionFn completion); +const char *_bugsee_test_exception_options(const char *optionsJson); } static int gStartedToken; @@ -205,6 +206,24 @@ int main(void) expect(kindColor == NULL, "a string property has no color value"); free(kindText); free(kindColor); + // LogException options reach BugseeExceptionLoggingOptions; unknown keys and wrong types are ignored. + expect(_bugsee_test_exception_options(NULL) == NULL, "no exception options means nil options"); + expect(_bugsee_test_exception_options("[1]") == NULL, "non-object exception options are ignored"); + value = _bugsee_test_exception_options( + "{\"labels\":[\"a\",3,\"b\"],\"includeVideo\":false,\"mergingRules\":{\"k\":1},\"domain\":\"x\"}"); + expect(value != NULL && strstr(value, "labels=a,b;video=0;") != NULL && strstr(value, "k = 1") != NULL, + "exception options carry labels, includeVideo and mergingRules"); + free((void *)value); + value = _bugsee_test_exception_options("{\"labels\":\"nope\",\"includeVideo\":\"yes\"}"); + expect(value != NULL && strstr(value, "labels=(null);video=1;") != NULL, "wrong-typed exception options are dropped"); + free((void *)value); + value = _bugsee_test_exception_options("{\"labels\":[\"only\"]}"); + expect(value != NULL && strstr(value, "labels=only;video=1;") != NULL, "options without includeVideo keep the video"); + free((void *)value); + value = _bugsee_test_exception_options("{\"skipFrames\":2,\"domain\":\"x\"}"); + expect(value != NULL && strstr(value, "video=1;") != NULL && strstr(value, "= 2") != NULL, + "top-level skipFrames becomes the skip-frames merging rule"); + free((void *)value); // Lifecycle against the real SDK: answers come back with the caller's token. _bugsee_launch("00000000-0000-0000-0000-000000000000", "{\"max_recording_time\":60}", 41, OnStarted); expect(waitFor(^{ return gStartedCount == 1 ? 1 : 0; }, 5) && gStartedToken == 41, From dff5a9c1cd4af1e1c88c52fe63791ec21ddaeaa9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:53:58 +0500 Subject: [PATCH 107/157] Expose LaunchAsync/RelaunchAsync/StopAsync on Android (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ILifecycleResultBridge on the Android bridge using the lifecycle machine's per-operation completions; add a Stop result completion that is true when the SDK reported the stop and false at the 10 s soft deadline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 3 +- Runtime/Bugsee.cs | 12 +- Runtime/Platform/Android/AndroidBridge.cs | 18 +- Runtime/Platform/ILifecycleResultBridge.cs | 4 +- .../WrapperPolicy/AndroidLifecycleMachine.cs | 85 ++++-- .../AndroidLaunchStatePolicyTests.cs | 274 +++++++++++++++++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 7 +- 7 files changed, 373 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7234111..0dbec6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS SPM-only native SDK: Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` + `Plugins/iOS/BugseeUnityBridge.mm` P/Invoke bridge (7.x selectors; iOS 15 deployment target). - ExceptionPipeline managed capture (`CaptureManagedExceptions`) with UnityManagedException JSON-in-reason. - `Bugsee.LogUnhandledException` on Android and iOS. -- `Bugsee.LaunchAsync`, `RelaunchAsync` (`Task`) and `StopAsync` (`Task`) on iOS: they complete when the SDK has answered. `StopAsync` faults with `TimeoutException` when the stop could not be started (the main thread did not take the call within 5 s) and also when the SDK has not reported the stop within 10 s; in that second case the native stop may still be running, and later launches stay deferred until it reports (or 60 s pass). The existing `Launch`/`Relaunch`/`Stop` signatures are unchanged. +- `Bugsee.LaunchAsync`, `RelaunchAsync` (`Task`) and `StopAsync` (`Task`) on iOS and Android: they complete when the SDK has answered. `StopAsync` faults with `TimeoutException` when the SDK has not reported the stop within 10 s of the call (the native stop may still be running, and later launches stay deferred until it reports, or 60 s pass); on iOS it also faults when the main thread did not take the call within 5 s. The existing `Launch`/`Relaunch`/`Stop` signatures are unchanged. +- On Android, `LaunchAsync`/`RelaunchAsync` complete true when the SDK accepted the launch and false when it refused it or the request was superseded; if the SDK gives no answer within 10 s they complete true when it is already Launching/Launched, otherwise false; `StopAsync` is not dropped by a Launch that follows it, and the 10 s deadline counts from each call, also for one that joins a stop already in flight. Android queues the call to the main thread and never refuses it, so a stalled main thread leaves the Task pending. - Editor IL2CPP linemap upload (`BugseeIl2CppLinemapUpload`) and symbol orchestrator (`BugseeSymbolUpload`: ELF / ProGuard / archive dSYM). - Hang / ANR / OOM launch-option docs and sample defaults. - iOS bridge depth: identity get/set, attribute get, appearance, report/upload labels. diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 77520c6..31f325c 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -85,7 +85,7 @@ public static Task LaunchAsync(string appToken, Dictionary throw new ArgumentException("appToken is required", nameof(appToken)); MainThreadDispatcher.Ensure(); - var result = new TaskCompletionSource(); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { bridge.Launch(appToken, options, accepted => result.TrySetResult(accepted)); @@ -101,7 +101,7 @@ public static Task LaunchAsync(string appToken, Dictionary /// that completes with the SDK's answer, as . public static Task RelaunchAsync(Dictionary options = null) { - var result = new TaskCompletionSource(); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { bridge.Relaunch(options, accepted => result.TrySetResult(accepted)); @@ -117,12 +117,14 @@ public static Task RelaunchAsync(Dictionary options = null /// /// that completes once the SDK has finished stopping. Unlike a Stop completion it /// completes even when a Launch follows before it does. Faults with if the - /// stop could not be handed to the Unity main thread, or the SDK has not reported it within 10 s (a Launch - /// still waits for that stop). + /// SDK has not reported the stop within 10 s of this call (a Launch still waits for that stop). On iOS it + /// also faults when the stop could not be handed to the Unity main thread within 5 s. On Android the + /// hand-off is queued and never refused, so if the Unity main thread is stalled the Task stays pending + /// (the 10 s deadline is itself driven from the main thread). /// public static Task StopAsync() { - var result = new TaskCompletionSource(); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { bridge.Stop(ran => diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 1da5d74..59586b3 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -15,7 +15,7 @@ namespace Bugsee.Platform.Android { - sealed class AndroidBridge : IBugseeNativeBridge + sealed class AndroidBridge : IBugseeNativeBridge, ILifecycleResultBridge { const string BugseeClass = "com.bugsee.library.Bugsee"; const string FeedbackClass = "com.bugsee.library.contracts.extensions.Feedback"; @@ -92,14 +92,26 @@ internal void Launch(string appToken, IDictionary options, Actio internal void Relaunch(IDictionary options, Action completion) => MainThreadDispatcher.Run(() => Lifecycle.Relaunch(options, completion)); - public void Stop(Action completion = null) + void ILifecycleResultBridge.Launch(string appToken, IDictionary options, Action started) => + Launch(appToken, options, started); + + void ILifecycleResultBridge.Relaunch(IDictionary options, Action started) => + Relaunch(options, started); + + // finished (StopAsync): true when the SDK reported the stop, false at the machine's 10 s soft deadline. The + // stop is queued to the main thread like every lifecycle call here, so it is never skipped. + void ILifecycleResultBridge.Stop(Action finished) => Stop(null, finished); + + public void Stop(Action completion = null) => Stop(completion, null); + + void Stop(Action completion, Action finished) { MainThreadDispatcher.Run(() => { AndroidManagedReportUploadFence.Invalidate(); _openReport?.ReleaseSnapshotFiles(); _openReport = null; - Lifecycle.Stop(completion); + Lifecycle.Stop(completion, finished); }); } diff --git a/Runtime/Platform/ILifecycleResultBridge.cs b/Runtime/Platform/ILifecycleResultBridge.cs index 2618461..dab6cb3 100644 --- a/Runtime/Platform/ILifecycleResultBridge.cs +++ b/Runtime/Platform/ILifecycleResultBridge.cs @@ -17,7 +17,9 @@ internal interface ILifecycleResultBridge void Relaunch(IDictionary options, Action started); /// runs once, when the stop is over: true when the stop ran (even - /// if a later Launch dropped the ordinary Stop completions), false when the main thread did not take it. + /// if a later Launch dropped the ordinary Stop completions), false when the SDK had not reported it within the + /// soft deadline (10 s from the call) or, on iOS, when the main thread did not take it. Android queues the call to + /// the main thread and never refuses it: a stalled main thread leaves pending. void Stop(Action finished); } } diff --git a/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs b/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs index 01e4d43..971585d 100644 --- a/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs +++ b/Runtime/WrapperPolicy/AndroidLifecycleMachine.cs @@ -45,7 +45,9 @@ internal interface IAndroidLifecycleNative /// - Two-stage deadline, because the SDK does not always call back (relaunch() refuses silently when it is no /// longer Launched) and a slow stop can outlive any wait: /// - Soft, after an operation started, ONLY answers its callers, with a warning, - /// so nobody hangs: Stop completions run, Launch/Relaunch/Delete completions get false. It releases nothing + /// so nobody hangs: Stop completions run, Launch/Relaunch completions get true when the SDK is already running + /// (Launching/Launched: it accepted the launch, only the callback is missing) and false otherwise, Delete + /// completions get false. It releases nothing /// else - the operation stays in flight, the queue stays blocked behind it, and a Launch/Relaunch requested /// meanwhile (also from inside such a Stop completion) stays deferred until the SDK actually reports (the /// stop finished, the relaunch continuation called back) or the hard deadline passes. The native operation @@ -79,11 +81,20 @@ sealed class Op public TimeSpan HardDeadline; } + // A Stop caller. Result (StopAsync): true when the stop really finished, false when the deadline answered + // first. The deadline counts from the call, also for a Stop that joins a stop already in flight. + sealed class StopWaiter + { + public Action Completion; + public Action Result; + public TimeSpan Deadline; + } + sealed class StopOp { - // Callers' Stop completions (answered at the soft deadline at the latest) and the machine's own + // Callers' Stop waiters (each answered at its own soft deadline at the latest) and the machine's own // continuations (run only when the stop really finished, or at the hard reset). - public readonly List Completions = new List(); + public readonly List Waiters = new List(); public readonly List Continuations = new List(); public int Token; public bool NativeDone; @@ -156,7 +167,10 @@ public void DeleteCollectedData(Action completion = null) Pump(); } - public void Stop(Action completion = null) + /// Runs once: true when the SDK reported the stop finished (also when a Launch came + /// meanwhile, unlike in other bridges), false when the soft deadline answered + /// first. Never dropped. + public void Stop(Action completion = null, Action result = null) { _stopEpoch++; _stopIntent = true; @@ -173,7 +187,7 @@ public void Stop(Action completion = null) } _native.UninstallHooks(); - StartStop(completion); + StartStop(completion, result: result); foreach (var op in dropped) Complete(op.Completions, false); } @@ -186,12 +200,16 @@ public void Tick() // is reset in the same tick does not start another one. if (_active != null && now >= _active.Deadline && _active.Completions.Count > 0) { + // A Launch/Relaunch the SDK already started (Launching/Launched) is answered true: it was accepted, + // only its final callback is missing. A Delete has no such state. + var accepted = _active.Kind != Kind.Delete && AndroidLaunchStatePolicy.IsRunning(_native.Status()); _native.Warn("Bugsee." + _active.Kind + " got no result from the SDK within " + - OperationTimeout.TotalSeconds + " s; it is reported as failed, but the SDK may still " + + OperationTimeout.TotalSeconds + " s; it is reported as " + + (accepted ? "started (the SDK is running)" : "failed") + ", but the SDK may still " + "complete it."); var answered = _active.Completions.ToArray(); _active.Completions.Clear(); - foreach (var c in answered) Run(() => c(false)); + foreach (var c in answered) Run(() => c(accepted)); } if (_active != null && now >= _active.HardDeadline) @@ -201,14 +219,15 @@ public void Tick() Settle(_active, false); } - if (_stop != null && now >= _stop.Deadline && _stop.Completions.Count > 0) + if (_stop != null && _stop.Waiters.Exists(w => now >= w.Deadline)) { _native.Warn("Bugsee.Stop did not complete within " + OperationTimeout.TotalSeconds + " s; its completion runs now. The SDK may still be stopping: a Launch requested now " + "waits until it has."); - var answered = _stop.Completions.ToArray(); - _stop.Completions.Clear(); - foreach (var c in answered) Run(c); + var nativeDone = _stop.NativeDone; + var late = _stop.Waiters.FindAll(w => now >= w.Deadline); + _stop.Waiters.RemoveAll(w => now >= w.Deadline); + foreach (var w in late) Answer(w, nativeDone); } if (_stop != null && now >= _stop.HardDeadline) @@ -216,7 +235,7 @@ public void Tick() _native.Warn("Bugsee.Stop did not complete within " + HardTimeout.TotalSeconds + " s; the wrapper stops waiting for it."); _skipStoppingJoin = true; - FinishStop(); + FinishStop(finished: false); } Pump(); @@ -402,11 +421,11 @@ void Settle(Op op, bool ok) if (op.Kind != Kind.Delete && _stop != null && _stop.NativeDone) FinishStop(); } - void StartStop(Action completion, bool restart = false, Action continuation = null) + void StartStop(Action completion, bool restart = false, Action continuation = null, Action result = null) { if (_stop != null && !restart) { - Add(_stop.Completions, completion); + AddWaiter(completion, result); Add(_stop.Continuations, continuation); return; } @@ -418,7 +437,7 @@ void StartStop(Action completion, bool restart = false, Action continuation = nu _native.ScheduleTick(HardTimeout); } - Add(_stop.Completions, completion); + AddWaiter(completion, result); Add(_stop.Continuations, continuation); var stop = _stop; stop.NativeDone = false; @@ -434,10 +453,42 @@ void StartStop(Action completion, bool restart = false, Action continuation = nu } } + void AddWaiter(Action completion, Action result) + { + if (completion == null && result == null) return; + // The SDK already reported this stop (FinishStop only waits for a Launch/Relaunch result): StopAsync is + // answered now, it must not time out on a stop that is over. + if (_stop.NativeDone && result != null) + { + Run(() => result(true)); + result = null; + if (completion == null) return; + } + _stop.Waiters.Add(new StopWaiter { Completion = completion, Result = result, Deadline = _now() + OperationTimeout }); + // A waiter that joins later than the stop's first one has a deadline of its own. + _native.ScheduleTick(OperationTimeout); + } + + void Answer(StopWaiter waiter, bool finished) + { + if (waiter.Completion != null) Run(waiter.Completion); + if (waiter.Result != null) Run(() => waiter.Result(finished)); + } + void OnStopDone(int token) { if (_stop == null || _stop.Token != token) return; _stop.NativeDone = true; + // StopAsync waiters are told now: the SDK reported the stop, even when FinishStop waits for a Launch/Relaunch + // result below (that wait is about the machine's continuations and plain completions). + var told = _stop.Waiters.FindAll(w => w.Result != null); + foreach (var w in told) + { + var result = w.Result; + w.Result = null; + if (w.Completion == null) _stop.Waiters.Remove(w); + Run(() => result(true)); + } // A Launch/Relaunch still in flight may launch() after this stop (a relaunch's continuation): wait for // its result, which stops it again if needed. if (_active != null && _active.Kind != Kind.Delete) return; @@ -445,11 +496,11 @@ void OnStopDone(int token) Pump(); } - void FinishStop() + void FinishStop(bool finished = true) { var stop = _stop; _stop = null; - foreach (var c in stop.Completions) Run(c); + foreach (var w in stop.Waiters) Answer(w, finished); foreach (var c in stop.Continuations) Run(c); } diff --git a/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs b/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs index 6f6a802..8a26682 100644 --- a/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs +++ b/Tests~/WrapperPolicy/AndroidLaunchStatePolicyTests.cs @@ -65,7 +65,9 @@ public void DeleteCollectedData(Action done) public void InstallHooks(IDictionary options) { HooksInstalled = true; HookOptions = options; } public void UninstallHooks() => HooksInstalled = false; - public void ScheduleTick(TimeSpan delay) { } + public Func Clock = () => TimeSpan.Zero; + public readonly List ScheduledTicks = new List(); + public void ScheduleTick(TimeSpan delay) => ScheduledTicks.Add(Clock() + delay); public void Warn(string message) => Warnings.Add(message); public void Error(Exception exception) => throw exception; @@ -116,6 +118,7 @@ public void SetUp() { _sdk = new FakeAndroidSdk(); _now = TimeSpan.Zero; + _sdk.Clock = () => _now; _machine = new AndroidLifecycleMachine(_sdk, () => _now); } @@ -127,6 +130,21 @@ void Launched(string token = "t1", IDictionary options = null) _sdk.Calls.Clear(); } + // Time passes, but Tick runs only when a tick the machine scheduled falls due (as in AndroidBridge). + void AdvanceFiringScheduledTicks(TimeSpan to) + { + while (true) + { + var due = _sdk.ScheduledTicks.FindAll(t => t <= to); + if (due.Count == 0) break; + due.Sort(); + _sdk.ScheduledTicks.Remove(due[0]); + _now = due[0] > _now ? due[0] : _now; + _machine.Tick(); + } + _now = to; + } + void Elapse(TimeSpan by) { _now += by; @@ -276,6 +294,260 @@ public void Stop_completion_runs_with_a_warning_when_the_sdk_never_finishes() Assert.That(stopped, Is.EqualTo(1)); } + [Test] + public void Stop_result_is_true_when_the_sdk_finishes_and_runs_once() + { + Launched(); + var results = new List(); + _machine.Stop(null, results.Add); + Assert.That(results, Is.Empty); + _sdk.FinishStop(); + Assert.That(results, Is.EqualTo(new[] { true })); + Elapse(AndroidLifecycleMachine.HardTimeout); + Assert.That(results, Is.EqualTo(new[] { true }), "no late deadline answer"); + } + + [Test] + public void A_stop_that_joins_a_slow_stop_has_its_own_soft_deadline() + { + Launched(); + var plain = 0; + _machine.Stop(() => plain++); // t=0, the SDK never reports + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(12)); + Assert.That(plain, Is.EqualTo(1), "the first Stop was answered at 10 s"); + + var results = new List(); + _machine.Stop(null, results.Add); // joins at t=12 + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(21)); + Assert.That(results, Is.Empty, "not before its own 10 s"); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(22.1)); + Assert.That(results, Is.EqualTo(new[] { false }), "answered 10 s after the join, not at the hard deadline"); + } + + [Test] + public void A_waiter_joined_just_before_the_hard_reset_is_not_told_the_stop_finished() + { + Launched(); + _machine.Stop(); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(55)); + var results = new List(); + _machine.Stop(null, results.Add); + AdvanceFiringScheduledTicks(AndroidLifecycleMachine.HardTimeout + TimeSpan.FromSeconds(1)); + Assert.That(results, Is.EqualTo(new[] { false })); + } + + [Test] + public void A_stop_that_joins_just_before_the_first_ones_deadline_still_gets_its_own_10_seconds() + { + Launched(); + _machine.Stop(); // t=0 + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(9)); + var results = new List(); + _machine.Stop(null, results.Add); // joins at t=9 + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(10.5)); + Assert.That(results, Is.Empty, "the first stop's deadline does not answer it"); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(19.1)); + Assert.That(results, Is.EqualTo(new[] { false })); + } + + [Test] + public void Launch_without_a_callback_is_answered_true_at_the_soft_deadline_when_the_sdk_is_launching() + { + bool? launched = null; + var answers = 0; + _machine.Launch("t1", OptionsA, ok => { launched = ok; answers++; }); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Launching)); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(launched, Is.True, "the SDK accepted it; only the callback is missing"); + Assert.That(_sdk.Warnings, Has.Some.Contains("reported as started")); + _sdk.FinishLaunch(); + Assert.That(answers, Is.EqualTo(1), "answered once"); + } + + [Test] + public void Launch_is_answered_false_at_the_soft_deadline_when_the_sdk_is_not_running() + { + bool? launched = null; + _machine.Launch("t1", OptionsA, ok => launched = ok); + _sdk.Current = BugseeStatus.Stopped; // e.g. stopped by something else; no callback + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(launched, Is.False); + Assert.That(_sdk.Warnings, Has.Some.Contains("reported as failed")); + } + + [Test] + public void Relaunch_is_answered_true_at_the_soft_deadline_when_its_continuation_is_launching() + { + Launched(); + bool? relaunched = null; + _machine.Relaunch(OptionsB, ok => relaunched = ok); + _sdk.FinishStop(); // the continuation launch() now runs and never reports + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Launching)); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(relaunched, Is.True); + } + + [Test] + public void Delete_is_always_answered_false_at_the_soft_deadline() + { + bool? deleted = null; + _machine.DeleteCollectedData(ok => deleted = ok); + _sdk.Current = BugseeStatus.Launched; // irrelevant for a delete + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(deleted, Is.False); + } + + [Test] + public void Stop_result_during_a_pending_launch_is_true_when_the_sdk_reports_the_stop_not_false_at_the_deadline() + { + Launched(); + _machine.Stop(); // settle: Stopped + _sdk.FinishStop(); + var launched = new List(); + var results = new List(); + _machine.Launch("t2", OptionsA, launched.Add); // callback stays pending + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(1)); + _machine.Stop(null, results.Add); + _sdk.FinishStop(); // the SDK reports the stop; FinishStop is deferred behind the launch + Assert.That(results, Is.EqualTo(new[] { true })); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(30)); + Assert.That(results, Is.EqualTo(new[] { true }), "no TimeoutException later, no double completion"); + } + + [Test] + public void Stop_result_during_a_relaunch_is_true_once_in_both_orders() + { + // Relaunch first, then Stop. + Launched(); + var results = new List(); + _machine.Relaunch(OptionsB); + _machine.Stop(null, results.Add); + _sdk.FinishStop(); // joiners (ours), then the relaunch continuation launch() + Assert.That(results, Is.EqualTo(new[] { true })); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(30)); + _sdk.FinishLaunch(); // resurrected, stopped again + _sdk.FinishStop(); + Assert.That(results, Is.EqualTo(new[] { true }), "answered once"); + Assert.That(_sdk.Current, Is.EqualTo(BugseeStatus.Stopped)); + + // Stop first, then Relaunch (Relaunch is accepted only while Launched, so the SDK is stopping: refused). + SetUp(); + Launched(); + results.Clear(); + _machine.Stop(null, results.Add); + _machine.Relaunch(OptionsB); + _sdk.FinishStop(); + Assert.That(results, Is.EqualTo(new[] { true })); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(30)); + Assert.That(results, Is.EqualTo(new[] { true })); + } + + [Test] + public void A_plain_stop_completion_still_waits_for_the_launch_result_while_the_result_is_already_answered() + { + Launched(); + _machine.Relaunch(OptionsB); + var plain = 0; + var results = new List(); + _machine.Stop(() => plain++, results.Add); + _sdk.FinishStop(); + Assert.That(results, Is.EqualTo(new[] { true })); + Assert.That(plain, Is.EqualTo(0), "the continuation may still launch"); + _sdk.FinishLaunch(); + _sdk.FinishStop(); + Assert.That(plain, Is.EqualTo(1)); + Assert.That(results, Is.EqualTo(new[] { true })); + } + + [Test] + public void A_stop_result_that_joins_after_the_sdk_reported_the_stop_is_true_at_once() + { + Launched(); + _machine.Stop(); + _sdk.FinishStop(); + var launched = new List(); + _machine.Launch("t2", OptionsA, launched.Add); // callback stays pending, so FinishStop is not deferred... + _machine.Stop(); // a Stop intent while the launch is pending; native stop starts + _sdk.FinishStop(); // reported; FinishStop waits for the launch result + var results = new List(); + _machine.Stop(null, results.Add); // joins after NativeDone + Assert.That(results, Is.EqualTo(new[] { true }), "no 10 s wait"); + var second = new List(); + _machine.Stop(null, second.Add); + Assert.That(second, Is.EqualTo(new[] { true }), "a second StopAsync too"); + AdvanceFiringScheduledTicks(TimeSpan.FromSeconds(30)); + Assert.That(results, Is.EqualTo(new[] { true })); + Assert.That(second, Is.EqualTo(new[] { true })); + } + + [Test] + public void A_plain_completion_joining_after_the_sdk_reported_the_stop_still_waits_for_the_launch_result() + { + Launched(); + _machine.Relaunch(OptionsB); + _machine.Stop(); + _sdk.FinishStop(); // reported; the continuation launch() is pending + var plain = 0; + var results = new List(); + _machine.Stop(() => plain++, results.Add); + Assert.That(results, Is.EqualTo(new[] { true })); + Assert.That(plain, Is.EqualTo(0)); + _sdk.FinishLaunch(); + _sdk.FinishStop(); + Assert.That(plain, Is.EqualTo(1)); + Assert.That(results, Is.EqualTo(new[] { true })); + } + + [Test] + public void Stop_result_is_true_at_once_when_already_stopped() + { + var results = new List(); + _machine.Stop(null, results.Add); + Assert.That(results, Is.EqualTo(new[] { true })); + } + + [Test] + public void Stop_result_is_false_at_the_soft_deadline_and_not_repeated_by_the_late_stop() + { + Launched(); + var results = new List(); + var completions = 0; + _machine.Stop(() => completions++, results.Add); + Elapse(AndroidLifecycleMachine.OperationTimeout); + Assert.That(results, Is.EqualTo(new[] { false })); + Assert.That(completions, Is.EqualTo(1)); + _sdk.FinishStop(); + Assert.That(results, Is.EqualTo(new[] { false })); + } + + [Test] + public void A_launch_after_the_stop_does_not_drop_the_stop_result() + { + Launched(); + var results = new List(); + bool? launched = null; + _machine.Stop(null, results.Add); + _machine.Launch("t2", OptionsA, ok => launched = ok); // waits for the stop + _sdk.FinishStop(); + Assert.That(results, Is.EqualTo(new[] { true })); + _sdk.FinishLaunch(); + Assert.That(launched, Is.True); + } + + [Test] + public void A_joining_stop_result_and_a_plain_stop_both_answer_on_one_native_stop() + { + Launched(); + var results = new List(); + var plain = 0; + _machine.Stop(() => plain++); + _machine.Stop(null, results.Add); + _sdk.FinishStop(); + Assert.That(plain, Is.EqualTo(1)); + Assert.That(results, Is.EqualTo(new[] { true })); + Assert.That(_sdk.Calls.FindAll(c => c == "stop"), Has.Count.EqualTo(1)); + } + [Test] public void Delete_reports_false_when_its_stop_is_slow_and_is_abandoned_at_the_hard_deadline() { diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 9aa82cb..933fcc9 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -279,8 +279,11 @@ public void Android_delete_collected_data_stops_then_deletes() Does.Contain("MainThreadDispatcher.Run(() => Lifecycle.Launch(appToken, options, completion))")); Assert.That(ExtractMethodBody(bridge, "internal void Relaunch(IDictionary options, Action completion)"), Does.Contain("Lifecycle.Relaunch(options, completion)")); - Assert.That(ExtractMethodBody(bridge, "public void Stop(Action completion = null)"), - Does.Contain("Lifecycle.Stop(completion)")); + Assert.That(ExtractMethodBody(bridge, "void Stop(Action completion, Action finished)"), + Does.Contain("Lifecycle.Stop(completion, finished)")); + Assert.That(bridge, Does.Contain("class AndroidBridge : IBugseeNativeBridge, ILifecycleResultBridge")); + Assert.That(ExtractMethodBody(bridge, "void ILifecycleResultBridge.Stop(Action finished)"), + Does.Contain("Stop(null, finished)")); foreach (var call in new[] { "\"launch\"", "\"relaunch\"", "\"stop\"", "\"deleteCollectedDataOnDevice\"" }) Assert.That(System.Text.RegularExpressions.Regex.Matches(bridge, "CallStatic\\(" + System.Text.RegularExpressions.Regex.Escape(call)).Count, Is.EqualTo(1), call + " is only called by the lifecycle machine's native side"); From fcd776522c1e021f6338a64f99b07edc71fcd621 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 23:06:27 +0500 Subject: [PATCH 108/157] Android: implement report Attributes/Attachments, fix attachment setters, map NetworkEvent.isOverride MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read getAttributes/getAttachments instead of empty stubs; call the fluent Attachment setters with an object return type; add INetworkEvent.IsOverride (Android maps NetworkEvent.isOverride, iOS keeps it managed-only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 + Runtime/Contracts/Exchange/INetworkEvent.cs | 6 ++ Runtime/Platform/Android/AndroidBridge.cs | 1 + .../Platform/Android/AndroidExchangeEvents.cs | 6 ++ Runtime/Platform/Android/AndroidReport.cs | 43 ++++++++++-- Runtime/Platform/Editor/EditorBridge.cs | 1 + Runtime/Platform/IOS/IosExchangeEvents.cs | 3 + .../AndroidReportBindingSourceTests.cs | 70 +++++++++++++++++++ 8 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 Tests~/WrapperPolicy/AndroidReportBindingSourceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7234111..36e7986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. - iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. +- Android `IReport.Attributes` and `IReport.Attachments` returned empty stubs; they now read the native report. Attachment name/file name/MIME type setters are now called with the fluent `Attachment` return type, so they resolve over JNI. +- Added `INetworkEvent.IsOverride` (Android: maps `NetworkEvent.isOverride`; iOS: no native equivalent, kept on the managed event only). - iOS appearance setters and getters were a no-op because `+[Bugsee getAppearance]` returns a `BugseeTheme`, not a `BGSAppearance`. They now map onto `BugseeTheme` (report/feedback colors, report placeholders). Properties with no iOS equivalent are skipped and read back null: Report `ActionBarTextColor`, `ActionBarButtonBackgroundClickedColor`, `SeverityLabelActiveColor`; Feedback `ActionBarButtonBackgroundClickedColor`, `DateTextColor`, `EmailSkipBackgroundClickedColor`, `EmailContinueClickedColor`, `InputTextHintColor`, `BottomDelimiterColor`, `LoadingBarBackgroundColor`, `LoadingTextColor`, `ErrorTextColor`, `VersionChangedBackgroundColor`, `VersionChangedTextColor`; `Notification::Title`. ### Added diff --git a/Runtime/Contracts/Exchange/INetworkEvent.cs b/Runtime/Contracts/Exchange/INetworkEvent.cs index f6a0a8c..d857abe 100644 --- a/Runtime/Contracts/Exchange/INetworkEvent.cs +++ b/Runtime/Contracts/Exchange/INetworkEvent.cs @@ -27,5 +27,11 @@ public interface INetworkEvent string ErrorDescription { get; set; } IDictionary Headers { get; set; } NetworkEventStage Stage { get; } + + /// + /// Android only (Bugsee Android 7.4+ NetworkEvent.isOverride). iOS has no equivalent: the value is + /// kept on the managed event but never sent to the native SDK. Defaults to false. + /// + bool IsOverride { get; set; } } } diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 1da5d74..6080477 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -182,6 +182,7 @@ static void CopyNetworkFields(INetworkEvent source, INetworkEvent target) target.ErrorShortMessage = source.ErrorShortMessage; target.ErrorDescription = source.ErrorDescription; target.Headers = source.Headers; + target.IsOverride = source.IsOverride; } public void Log(string message, LogLevel level) diff --git a/Runtime/Platform/Android/AndroidExchangeEvents.cs b/Runtime/Platform/Android/AndroidExchangeEvents.cs index 32d1059..d5aca12 100644 --- a/Runtime/Platform/Android/AndroidExchangeEvents.cs +++ b/Runtime/Platform/Android/AndroidExchangeEvents.cs @@ -105,6 +105,12 @@ public IDictionary Headers } } + public bool IsOverride + { + get => _native.Call("isOverride"); + set => _native.Call("setOverride", value); + } + public NetworkEventStage Stage { get diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index 7c641b8..dc4e9eb 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -76,7 +76,14 @@ public IssueSeverity? Severity } } - public IReadOnlyDictionary Attributes => new Dictionary(); + public IReadOnlyDictionary Attributes + { + get + { + using (var map = _report.Call("getAttributes")) + return AndroidJavaConverters.MapToDictionary(map); + } + } public object GetAttribute(string name) { @@ -125,8 +132,26 @@ public void SetLabels(IEnumerable labels) } } - public IReadOnlyList Attachments => new List(); + public IReadOnlyList Attachments + { + get + { + var list = new List(); + using (var javaList = _report.Call("getAttachments")) + { + if (javaList == null) return list; + var size = javaList.Call("size"); + for (var i = 0; i < size; i++) + { + var att = javaList.Call("get", i); + if (att != null) list.Add(new AndroidAttachment(att)); + } + } + return list; + } + } + // addAttachment returns null when the report refuses it (not live, attachment limit); keep that as null. public IAttachment AddAttachmentFile(string path, string name, string mimeType) { if (string.IsNullOrEmpty(path)) @@ -162,22 +187,30 @@ public AndroidAttachment(AndroidJavaObject attachment) _attachment = attachment; } + // The Java setters are fluent (return Attachment); a void-typed JNI call would not resolve them. + void Set(string method, string value) + { + using (_attachment.Call(method, value)) + { + } + } + public string Name { get => _attachment.Call("getName"); - set => _attachment.Call("setName", value); + set => Set("setName", value); } public string Filename { get => _attachment.Call("getFileName", (string)null); - set => _attachment.Call("setFileName", value); + set => Set("setFileName", value); } public string MimeType { get => _attachment.Call("getMimeType"); - set => _attachment.Call("setMimeType", value); + set => Set("setMimeType", value); } public void SetData(byte[] data) diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index c591817..d7239b6 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -147,6 +147,7 @@ public EditorNetworkEvent( public string ErrorShortMessage { get; set; } public string ErrorDescription { get; set; } public IDictionary Headers { get; set; } + public bool IsOverride { get; set; } public NetworkEventStage Stage => _stage; } } diff --git a/Runtime/Platform/IOS/IosExchangeEvents.cs b/Runtime/Platform/IOS/IosExchangeEvents.cs index d621e2b..2d83aac 100644 --- a/Runtime/Platform/IOS/IosExchangeEvents.cs +++ b/Runtime/Platform/IOS/IosExchangeEvents.cs @@ -118,6 +118,9 @@ public IDictionary Headers } } + // No iOS counterpart: kept on the managed event only, not part of the DTO or the channel JSON. + public bool IsOverride { get; set; } + public NetworkEventStage Stage => ParseStage(_dto.stage); public string ToResultJson() diff --git a/Tests~/WrapperPolicy/AndroidReportBindingSourceTests.cs b/Tests~/WrapperPolicy/AndroidReportBindingSourceTests.cs new file mode 100644 index 0000000..d066375 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidReportBindingSourceTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// Pins AndroidReport to Java Report methods that exist in Bugsee Android 7.4.x. + public class AndroidReportBindingSourceTests + { + static string Source() => File.ReadAllText(Path.Combine(RepoRoot(), "Runtime/Platform/Android/AndroidReport.cs")); + + static string RepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + [Test] + public void Report_reads_attributes_and_attachments_from_native() + { + var src = Source(); + Assert.That(src, Does.Contain("\"getAttributes\"")); + Assert.That(src, Does.Contain("\"getAttachments\"")); + } + + [Test] + public void Attachments_keep_the_null_on_refusal_addAttachment_overloads() + { + var src = Source(); + Assert.That(src, Does.Contain("\"addAttachment\", file, attachmentName, mimeType, false")); + Assert.That(src, Does.Contain("\"addAttachment\", data, attachmentName, mimeType")); + Assert.That(src, Does.Not.Contain("\"createAndAddAttachment\"")); + } + + [Test] + public void Fluent_attachment_setters_are_called_with_an_object_return_type() + { + var src = Source(); + Assert.That(src, Does.Not.Contain("_attachment.Call(\"setName\"")); + Assert.That(src, Does.Not.Contain("_attachment.Call(\"setFileName\"")); + Assert.That(src, Does.Not.Contain("_attachment.Call(\"setMimeType\"")); + } + + [Test] + public void Android_network_event_maps_isOverride_and_ios_ignores_it() + { + string root = RepoRoot(); + string android = File.ReadAllText(Path.Combine(root, "Runtime/Platform/Android/AndroidExchangeEvents.cs")); + Assert.That(android, Does.Contain("_native.Call(\"isOverride\")")); + Assert.That(android, Does.Contain("_native.Call(\"setOverride\", value)")); + + string bridge = File.ReadAllText(Path.Combine(root, "Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(bridge, Does.Contain("target.IsOverride = source.IsOverride;")); + + string ios = File.ReadAllText(Path.Combine(root, "Runtime/Platform/IOS/IosExchangeEvents.cs")); + Assert.That(ios, Does.Contain("public bool IsOverride { get; set; }")); + Assert.That(ios, Does.Not.Contain("_dto.isOverride")); + + string contract = File.ReadAllText(Path.Combine(root, "Runtime/Contracts/Exchange/INetworkEvent.cs")); + Assert.That(contract, Does.Contain("bool IsOverride { get; set; }")); + } + } +} From 06f9169590f5a4787d8e4ebded792bb6169d4061 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 22:58:29 +0500 Subject: [PATCH 109/157] Return typed attribute values from iOS GetAttribute. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 7.0.0-beta6 SDK already keeps a scalar's kind (NSString, NSNumber, boolean), so the bridge only flattened it with -description. _bugsee_get_attribute now returns a type-tagged value (s/b/i/d/j) that C# decodes (AttributeWire), and whole floating values are sent as 1.0 so they stay floating. Reports still carry the plain value. Documents the GetAttribute contract on both platforms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 9 +- Documentation~/index.md | 4 + Plugins/iOS/BugseeUnityBridge.mm | 31 ++++-- Runtime/Platform/Android/AndroidBridge.cs | 2 +- Runtime/Platform/Android/AndroidReport.cs | 2 +- Runtime/Platform/IOS/IOSBridge.cs | 7 +- Runtime/WrapperPolicy/AttributeWire.cs | 71 ++++++++++++ Runtime/WrapperPolicy/AttributeWire.cs.meta | 11 ++ Tests~/WrapperPolicy/AttributeWireTests.cs | 105 ++++++++++++++++++ Tests~/WrapperPolicy/ReportPathSourceTests.cs | 4 + Tests~/iOS/BridgeTests.mm | 45 ++++++-- 11 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 Runtime/WrapperPolicy/AttributeWire.cs create mode 100644 Runtime/WrapperPolicy/AttributeWire.cs.meta create mode 100644 Tests~/WrapperPolicy/AttributeWireTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7234111..0ec51d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,8 +78,13 @@ Behaviour changes to be aware of. 0.1.0 is not released, so these correct earlie - iOS wrapper-channel network events are sent with the SDK's own event types (`before`, `complete`, `cancel`, `error`) and a Unix-millisecond timestamp. Events with a stage iOS cannot represent (`Redirect`, `RequestTimingsReceived` and `WebSocket`) are **dropped with a warning**. -- iOS `GetAttribute` returns the text form of a scalar (`"1"` for the number 1, the string "1" and the bool true; - `"0"` for false). Android returns typed values. +- `GetAttribute` keeps the kind of a scalar on both platforms: a string reads back as `string`, a bool as `bool`, + an integer input as an integer and a floating-point input as floating point (a whole-valued one such as `1.0` + stays floating point on iOS, it does not become an integer), so the string "1", the number 1 and `true` + are distinct. The width is not promised (Android returns the boxed type that was set, with `sbyte`/`ushort` + stored as `int`, `uint`/`ulong` as `long` and `decimal` as `double`; iOS returns `long` and `double`), so + compare with `Convert` rather than a cast. A container set natively (not through this API) reads as a + `Dictionary` on Android and as its JSON text on iOS. Reports still show the natural value. - iOS launch and relaunch now send `wrapper_info` (type, version, build, metal, fps) unless the caller supplied one. This changes the SDK's video capture to its Unity path. `fps` is `Application.targetFrameRate` (30 when unset), never above `Screen.currentResolution.refreshRate` because the SDK uses it as a frame-skip divisor; it diff --git a/Documentation~/index.md b/Documentation~/index.md index 7d94f12..b1282d2 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -183,4 +183,8 @@ Managed error/crash **grouping** (one signature per event, Unity-primary when st Wired: Launch/Stop, blackout, log/trace/event, exceptions (JSON-in-reason), TestCrash, report/upload (+ labels), identity (email API), attributes, secure rects, appearance colors/strings, feedback UI, network/log/breadcrumb filters, report handler (via `BugseeWrapper`), lifecycle listener (via wrapper `onLifecycleEvent` → `Bugsee.LifecycleEvent` / `ILifecycleEventListener`). +### Attributes + +`SetAttribute` accepts a string (up to 1024 UTF-16 units), a bool or a number within the signed 64-bit range; anything else throws `ArgumentException`. `GetAttribute` returns the kind that was set: `string`, `bool`, an integer or a `double`. The numeric width is not promised (Android returns the boxed type that was set, with `sbyte`/`ushort` stored as `int`, `uint`/`ulong` as `long` and `decimal` as `double`; iOS returns `long` or `double`), and a 1.0 set as floating point stays floating. A container set natively reads as a `Dictionary` on Android and as JSON text on iOS. On iOS the bridge returns the value as a type tag and payload (`s:`, `b:`, `i:`, `d:`, `j:`; see `AttributeWire`); the SDK stores the plain value, so reports show it unwrapped. + Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index d2b4fb5..409e832 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -448,16 +448,33 @@ void _bugsee_set_attribute(const char *key, const char *valueJson) if (!attr) { return NULL; } - // dataWithJSONObject: throws on a bare string/number, so only serialize containers. - if (([attr isKindOfClass:[NSDictionary class]] || [attr isKindOfClass:[NSArray class]]) && - [NSJSONSerialization isValidJSONObject:attr]) { + // The SDK keeps the value's kind. Tag it for C# ("s:" string, "b:" bool 1/0, "i:" integer, "d:" floating + // point, "j:" JSON container) so a string "1", the number 1 and true stay distinguishable. + // locale:nil keeps the digits POSIX; C# parses them with the invariant culture. + NSString *wire = nil; + if ([attr isKindOfClass:[NSString class]]) { + wire = [@"s:" stringByAppendingString:attr]; + } else if ([attr isKindOfClass:[NSNumber class]]) { + NSNumber *number = attr; + char type = [number objCType][0]; + if (CFGetTypeID((__bridge CFTypeRef)number) == CFBooleanGetTypeID()) { + wire = [[NSString alloc] initWithFormat:@"b:%d" locale:nil, [number boolValue] ? 1 : 0]; + } else if (type == 'f' || type == 'd') { + wire = [[NSString alloc] initWithFormat:@"d:%.17g" locale:nil, [number doubleValue]]; + } else if (type == 'Q' || type == 'L') { + wire = [[NSString alloc] initWithFormat:@"i:%llu" locale:nil, [number unsignedLongLongValue]]; + } else { + wire = [[NSString alloc] initWithFormat:@"i:%lld" locale:nil, [number longLongValue]]; + } + } else if (([attr isKindOfClass:[NSDictionary class]] || [attr isKindOfClass:[NSArray class]]) && + [NSJSONSerialization isValidJSONObject:attr]) { NSData *data = [NSJSONSerialization dataWithJSONObject:attr options:0 error:nil]; - if (data) { - NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - return BugseeCopyUTF8(json); + NSString *json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil; + if (json) { + wire = [@"j:" stringByAppendingString:json]; } } - return BugseeCopyUTF8([attr description]); + return BugseeCopyUTF8(wire ?: [@"s:" stringByAppendingString:[attr description]]); } void _bugsee_clear_attribute(const char *key) diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index 1da5d74..8c5b37e 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -342,7 +342,7 @@ public void SetAttribute(string key, object value) if (!decision.Accepted) throw new ArgumentException(decision.Error, nameof(value)); - _bugsee.CallStatic("setAttribute", key, Box(value)); + _bugsee.CallStatic("setAttribute", key, Box(AttributeWire.ToBoxable(value))); if (GetAttribute(key) == null) throw new ArgumentException("attribute '" + key + "' was dropped", nameof(value)); } diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index 7c641b8..36287ca 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -86,7 +86,7 @@ public object GetAttribute(string name) public void SetAttribute(string name, object value) { - using (var boxed = AndroidJavaConverters.Box(value)) + using (var boxed = AndroidJavaConverters.Box(AttributeWire.ToBoxable(value))) _report.Call("setAttribute", name, boxed); } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 316313c..452c5d7 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -678,16 +678,15 @@ public void SetAttribute(string key, object value) if (!decision.Accepted) throw new ArgumentException(decision.Error, nameof(value)); - _bugsee_set_attribute(key ?? "", ToJsonValue(value)); + _bugsee_set_attribute(key ?? "", AttributeWire.ToJson(value)); if (GetAttribute(key) == null) throw new ArgumentException("attribute '" + key + "' was dropped", nameof(value)); } public object GetAttribute(string key) { - // Containers come back as JSON; scalars come back as their text form, so a string "1", - // the number 1 and the bool true all read as "1" (Android returns typed values). - return ConsumeNativeString(_bugsee_get_attribute(key ?? "")); + // Typed like Android: string, bool, long or double; a container set natively reads as its JSON text. + return AttributeWire.Decode(ConsumeNativeString(_bugsee_get_attribute(key ?? ""))); } public void ClearAttribute(string key) => _bugsee_clear_attribute(key ?? ""); diff --git a/Runtime/WrapperPolicy/AttributeWire.cs b/Runtime/WrapperPolicy/AttributeWire.cs new file mode 100644 index 0000000..69b2a38 --- /dev/null +++ b/Runtime/WrapperPolicy/AttributeWire.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; + +namespace Bugsee.WrapperPolicy +{ + /// + /// iOS attribute wire. Set sends the value as a JSON fragment; get returns a one-letter type tag, a colon and + /// the payload (s string, b bool as 1/0, i integer, d floating point, j JSON container), so the native + /// SDK's value kind survives the trip as it does on Android. Text without a known tag is returned as is. + /// + public static class AttributeWire + { + public static string ToJson(object value) + { + // JsonWire writes 1.0 as 1, which the SDK would store as an integer. + // A decimal is floating point on Android (ToBoxable), so it must be on iOS too. + if (value is decimal m) value = (double)m; + string text = JsonWire.Value(value); + if ((value is float || value is double) && text.IndexOfAny(new[] { '.', 'E' }) < 0) + return text + ".0"; + return text; + } + + /// + /// Maps the numeric types the Android boxer has no Java class for onto ones it has (sbyte and ushort to int, + /// uint and ulong to long, decimal to double), so they read back as numbers rather than as text. Call after + /// , which bounds ulong and decimal to the signed 64-bit range. + /// + public static object ToBoxable(object value) + { + switch (value) + { + case sbyte sb: return (int)sb; + case ushort us: return (int)us; + case uint ui: return (long)ui; + case ulong ul: return ul <= long.MaxValue ? (object)(long)ul : (double)ul; + case decimal m: return (double)m; + default: return value; + } + } + + public static object Decode(string wire) + { + if (wire == null) return null; + if (wire.Length < 2 || wire[1] != ':') return wire; + + string body = wire.Substring(2); + switch (wire[0]) + { + case 's': + case 'j': + return body; + case 'b': + if (body == "1") return true; + if (body == "0") return false; + break; + case 'i': + if (long.TryParse(body, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long l)) + return l; + if (ulong.TryParse(body, NumberStyles.None, CultureInfo.InvariantCulture, out ulong u)) + return (double)u; + break; + case 'd': + if (double.TryParse(body, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) + return d; + break; + } + return wire; + } + } +} diff --git a/Runtime/WrapperPolicy/AttributeWire.cs.meta b/Runtime/WrapperPolicy/AttributeWire.cs.meta new file mode 100644 index 0000000..608474e --- /dev/null +++ b/Runtime/WrapperPolicy/AttributeWire.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d1f7c0a9b3e4a2f8c6e1d7b4a9f3c25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/AttributeWireTests.cs b/Tests~/WrapperPolicy/AttributeWireTests.cs new file mode 100644 index 0000000..b2ddb22 --- /dev/null +++ b/Tests~/WrapperPolicy/AttributeWireTests.cs @@ -0,0 +1,105 @@ +using System.Globalization; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AttributeWireTests + { + [Test] + public void Tagged_scalars_decode_to_their_kind() + { + Assert.That(AttributeWire.Decode("s:1"), Is.EqualTo("1")); + Assert.That(AttributeWire.Decode("s:"), Is.EqualTo("")); + Assert.That(AttributeWire.Decode("s:a:b"), Is.EqualTo("a:b")); + Assert.That(AttributeWire.Decode("i:1"), Is.EqualTo(1L)); + Assert.That(AttributeWire.Decode("i:-7"), Is.EqualTo(-7L)); + Assert.That(AttributeWire.Decode("i:9007199254740993"), Is.EqualTo(9007199254740993L)); + Assert.That(AttributeWire.Decode("d:1.5"), Is.EqualTo(1.5d)); + Assert.That(AttributeWire.Decode("d:1"), Is.EqualTo(1d).And.TypeOf()); + Assert.That(AttributeWire.Decode("b:1"), Is.EqualTo(true)); + Assert.That(AttributeWire.Decode("b:0"), Is.EqualTo(false)); + } + + [Test] + public void The_string_one_the_number_one_and_true_stay_distinct() + { + Assert.That(AttributeWire.Decode("s:1"), Is.TypeOf()); + Assert.That(AttributeWire.Decode("i:1"), Is.TypeOf()); + Assert.That(AttributeWire.Decode("b:1"), Is.TypeOf()); + } + + [Test] + public void Containers_stay_json_text_and_unknown_text_is_returned_as_is() + { + Assert.That(AttributeWire.Decode("j:{\"a\":1}"), Is.EqualTo("{\"a\":1}")); + Assert.That(AttributeWire.Decode(null), Is.Null); + Assert.That(AttributeWire.Decode("plain"), Is.EqualTo("plain")); + Assert.That(AttributeWire.Decode("x:1"), Is.EqualTo("x:1")); + Assert.That(AttributeWire.Decode("b:2"), Is.EqualTo("b:2")); + Assert.That(AttributeWire.Decode("i:abc"), Is.EqualTo("i:abc")); + } + + [Test] + public void Integers_above_signed_64_bit_decode_as_double() + { + Assert.That(AttributeWire.Decode("i:18446744073709551615"), Is.EqualTo(18446744073709551615d)); + } + + [Test] + public void Numeric_types_without_a_java_box_map_to_ones_with_one() + { + Assert.That(AttributeWire.ToBoxable((sbyte)-3), Is.EqualTo(-3).And.TypeOf()); + Assert.That(AttributeWire.ToBoxable((ushort)65535), Is.EqualTo(65535).And.TypeOf()); + Assert.That(AttributeWire.ToBoxable(4000000000u), Is.EqualTo(4000000000L).And.TypeOf()); + Assert.That(AttributeWire.ToBoxable(1UL), Is.EqualTo(1L).And.TypeOf()); + Assert.That(AttributeWire.ToBoxable(ulong.MaxValue), Is.TypeOf()); + Assert.That(AttributeWire.ToBoxable(1.5m), Is.EqualTo(1.5d).And.TypeOf()); + Assert.That(AttributeWire.ToBoxable(7), Is.EqualTo(7).And.TypeOf()); + Assert.That(AttributeWire.ToBoxable("x"), Is.EqualTo("x")); + Assert.That(AttributeWire.ToBoxable(null), Is.Null); + } + + [Test] + public void Decimal_input_reads_back_as_double_on_the_ios_decode_path() + { + // The SDK stores 1.0 as a double and the bridge tags it d: (see BridgeTests "wholedouble"). + Assert.That(AttributeWire.ToJson(1m), Does.Contain(".")); + Assert.That(AttributeWire.Decode("d:1"), Is.TypeOf()); + Assert.That(AttributeWire.Decode("d:10"), Is.EqualTo(10d).And.TypeOf()); + } + + [Test] + public void Whole_floating_values_are_sent_with_a_decimal_point_so_they_stay_floating() + { + Assert.That(AttributeWire.ToJson(1d), Is.EqualTo("1.0")); + Assert.That(AttributeWire.ToJson(1f), Is.EqualTo("1.0")); + Assert.That(AttributeWire.ToJson(1.5d), Is.EqualTo("1.5")); + Assert.That(AttributeWire.ToJson(1e20d), Does.Contain("E")); + Assert.That(AttributeWire.ToJson(1m), Is.EqualTo("1.0")); + Assert.That(AttributeWire.ToJson(10m), Is.EqualTo("10.0")); + Assert.That(AttributeWire.ToJson(1.5m), Is.EqualTo("1.5")); + Assert.That(AttributeWire.ToJson(3), Is.EqualTo("3")); + Assert.That(AttributeWire.ToJson(true), Is.EqualTo("true")); + Assert.That(AttributeWire.ToJson("1"), Is.EqualTo("\"1\"")); + } + + [Test] + public void Wire_text_is_culture_invariant() + { + var saved = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.That((1.5).ToString(CultureInfo.CurrentCulture), Is.EqualTo("1,5")); + Assert.That(AttributeWire.Decode("d:1.5"), Is.EqualTo(1.5d)); + Assert.That(AttributeWire.Decode("i:-1234567"), Is.EqualTo(-1234567L)); + Assert.That(AttributeWire.ToJson(1.5d), Is.EqualTo("1.5")); + Assert.That(AttributeWire.ToJson(2d), Is.EqualTo("2.0")); + } + finally + { + CultureInfo.CurrentCulture = saved; + } + } + } +} diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index 7c9e23b..725d69f 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -194,6 +194,7 @@ public void Bridges_enforce_attribute_and_user_identifier_policy() Assert.That(readBack, Is.GreaterThan(nativeSet)); Assert.That(androidSetAttr, Does.Contain("attribute '")); Assert.That(androidSetAttr, Does.Contain("' was dropped")); + Assert.That(androidSetAttr, Does.Contain("Box(AttributeWire.ToBoxable(value))")); string androidSetUser = ExtractMethodBody(android, "public void SetUserIdentifier(string userIdentifier)"); Assert.That(androidSetUser, Does.Contain("UserIdentifierPolicy.ForSet")); @@ -210,6 +211,9 @@ public void Bridges_enforce_attribute_and_user_identifier_policy() Assert.That(readBack, Is.GreaterThan(nativeSet)); Assert.That(iosSetAttr, Does.Contain("attribute '")); Assert.That(iosSetAttr, Does.Contain("' was dropped")); + Assert.That(iosSetAttr, Does.Contain("_bugsee_set_attribute(key ?? \"\", AttributeWire.ToJson(value))")); + string iosGetAttr = ExtractMethodBody(ios, "public object GetAttribute(string key)"); + Assert.That(iosGetAttr, Does.Contain("AttributeWire.Decode(ConsumeNativeString(_bugsee_get_attribute(")); string iosSetUser = ExtractMethodBody(ios, "public void SetUserIdentifier(string userIdentifier)"); Assert.That(iosSetUser, Does.Contain("UserIdentifierPolicy.ForSet")); diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index 9c25722..d7dc863 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -1,6 +1,7 @@ #include #include #include +#include #import #import #import @@ -110,21 +111,26 @@ int main(void) free((void *)value); // A scalar attribute must not throw from NSJSONSerialization (top-level non-container), and - // reads back as its text form: the string, the number, 1/0 for a bool. + // reads back tagged with its kind: s string, b bool, i integer, d floating point, j container. struct { const char *name; const char *json; const char *expected; } scalars[] = { - { "text", "\"text\"", "text" }, - { "number", "1", "1" }, - { "decimal", "1.5", "1.5" }, - { "yes", "true", "1" }, - { "no", "false", "0" }, - { "empty", "\"\"", "" }, - { "unicode", "\"h\\u00e9llo\"", "h\xc3\xa9llo" }, + { "text", "\"text\"", "s:text" }, + { "numtext", "\"1\"", "s:1" }, + { "number", "1", "i:1" }, + { "negative", "-7", "i:-7" }, + { "bigint", "9007199254740993", "i:9007199254740993" }, + { "decimal", "1.5", "d:1.5" }, + { "wholedouble", "1.0", "d:1" }, + { "yes", "true", "b:1" }, + { "no", "false", "b:0" }, + { "empty", "\"\"", "s:" }, + { "unicode", "\"h\\u00e9llo\"", "s:h\xc3\xa9llo" }, + { "container", "{\"a\":1}", "j:{\"a\":1}" }, }; for (auto &c : scalars) { _bugsee_set_attribute(c.name, c.json); char *attr = _bugsee_get_attribute(c.name); char message[96]; - snprintf(message, sizeof message, "scalar attribute %s reads back as its text", c.name); + snprintf(message, sizeof message, "attribute %s reads back with its kind", c.name); expect(attr != NULL && same(attr, c.expected), message); free(attr); _bugsee_clear_attribute(c.name); @@ -224,6 +230,27 @@ int main(void) expect(value != NULL && strstr(value, "video=1;") != NULL && strstr(value, "= 2") != NULL, "top-level skipFrames becomes the skip-frames merging rule"); free((void *)value); + // The wire's digits must not follow the user's locale: C# parses them as invariant. + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults setObject:@"de_DE" forKey:@"AppleLocale"]; + [defaults setObject:@[@"de"] forKey:@"AppleLanguages"]; + setlocale(LC_ALL, "de_DE"); + expect([[NSString localizedStringWithFormat:@"%.1f", 1.5] containsString:@","], + "the test locale really uses a decimal comma"); + _bugsee_set_attribute("localized", "1.5"); + char *localized = _bugsee_get_attribute("localized"); + expect(localized != NULL && same(localized, "d:1.5"), "floating attribute stays POSIX under a comma locale"); + free(localized); + _bugsee_clear_attribute("localized"); + _bugsee_set_attribute("localized", "1234567"); + localized = _bugsee_get_attribute("localized"); + expect(localized != NULL && same(localized, "i:1234567"), "integer attribute has no grouping under a comma locale"); + free(localized); + _bugsee_clear_attribute("localized"); + setlocale(LC_ALL, "C"); + [defaults removeObjectForKey:@"AppleLocale"]; + [defaults removeObjectForKey:@"AppleLanguages"]; + // Lifecycle against the real SDK: answers come back with the caller's token. _bugsee_launch("00000000-0000-0000-0000-000000000000", "{\"max_recording_time\":60}", 41, OnStarted); expect(waitFor(^{ return gStartedCount == 1 ? 1 : 0; }, 5) && gStartedToken == 41, From 18c162daa9d224fe4e735ea3d03b1cb565a6a08e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 23:46:31 +0500 Subject: [PATCH 110/157] Document Launch/Relaunch/Stop/Delete guarantees, deadlines and residual races per platform. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 7 +++++-- Documentation~/index.md | 31 +++++++++++++++++++++++++++++++ Runtime/Bugsee.cs | 40 +++++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 784b229..8ec8e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. + - Android Mono builds cannot symbolicate native frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`): Unity 2021.3 has no Mono ARM64 and those libraries have no GNU build-id. IL2CPP is the supported configuration for fully symbolicated native crashes. Documented in the Player Settings checklist, and a Mono Android build now logs a one-time warning at build time regardless of symbol upload settings (the build is never failed). ### Fixed @@ -28,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ExceptionPipeline managed capture (`CaptureManagedExceptions`) with UnityManagedException JSON-in-reason. - `Bugsee.LogUnhandledException` on Android and iOS. - `Bugsee.LaunchAsync`, `RelaunchAsync` (`Task`) and `StopAsync` (`Task`) on iOS and Android: they complete when the SDK has answered. `StopAsync` faults with `TimeoutException` when the SDK has not reported the stop within 10 s of the call (the native stop may still be running, and later launches stay deferred until it reports, or 60 s pass); on iOS it also faults when the main thread did not take the call within 5 s. The existing `Launch`/`Relaunch`/`Stop` signatures are unchanged. -- On Android, `LaunchAsync`/`RelaunchAsync` complete true when the SDK accepted the launch and false when it refused it or the request was superseded; if the SDK gives no answer within 10 s they complete true when it is already Launching/Launched, otherwise false; `StopAsync` is not dropped by a Launch that follows it, and the 10 s deadline counts from each call, also for one that joins a stop already in flight. Android queues the call to the main thread and never refuses it, so a stalled main thread leaves the Task pending. +- On Android, `LaunchAsync`/`RelaunchAsync` complete true when the SDK accepted the launch and false when it refused it or the request was superseded; if the SDK gives no answer within 10 s they complete true when it is already Launching/Launched, otherwise false; `StopAsync` is not dropped by a Launch that follows it, and the 10 s deadline counts from each call, also for one that joins a stop already in flight. Android hands every lifecycle call to the Unity main thread (from another thread it is queued there and never refused), so a stalled main thread leaves the Task pending. - Editor IL2CPP linemap upload (`BugseeIl2CppLinemapUpload`) and symbol orchestrator (`BugseeSymbolUpload`: ELF / ProGuard / archive dSYM). - Hang / ANR / OOM launch-option docs and sample defaults. - iOS bridge depth: identity get/set, attribute get, appearance, report/upload labels. @@ -74,7 +76,8 @@ Behaviour changes to be aware of. 0.1.0 is not released, so these correct earlie the SDK still stopping waits for that stop (up to 10 s) instead of stopping it again; if it never stops, or the SDK reports the wipe failed, a warning is logged and a waiting `Launch` still proceeds. - Android `Launch`, `Relaunch` and `DeleteCollectedDataOnDevice` run one at a time, in request order, on the main - thread, and `Stop` is never queued (calls from other threads are asynchronous): a `Launch` or `Relaunch` during a stop is deferred, a + thread, while `Stop` is not held in that order (it starts or joins a native stop at once, and drops queued + `Launch`/`Relaunch` requests; only the hand-off from another thread is queued, as for every Android lifecycle call): a `Launch` or `Relaunch` during a stop is deferred, a `Relaunch` is ignored with a warning unless the SDK is `Launched`, a refused `Launch` no longer cancels a pending delete, and a late launch result after a newer `Stop` is stopped again. In-flight operations answer their callers after 10 s and are abandoned after 60 s. diff --git a/Documentation~/index.md b/Documentation~/index.md index 7d94f12..c47289a 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -49,6 +49,37 @@ Bugsee.Launch(appToken, options); `CaptureManagedExceptions` is independent of `DetectAndReportCrash`. Omit it to keep the default (enabled). +## Lifecycle: Launch, Relaunch, Stop, Delete + +`Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` do not wait for the native SDK to finish. They are handed to the Unity main thread. Called from another thread, on Android the call is queued there and never refused (it waits for the main thread). On iOS the caller waits up to 5 s for the main thread to take it; if it does not, the request does not run (an error is logged, `LaunchAsync`/`RelaunchAsync` complete `false`, `StopAsync` faults with `TimeoutException`). + +Ordering differs per platform, and holds for the wrapper's scheduling before the hard deadline below: + +- **Android**: `Launch`, `Relaunch` and `DeleteCollectedDataOnDevice` run one at a time, in request order. A queued `Launch` replaces earlier queued `Launch`/`Relaunch` requests after the last queued Delete, and a `Relaunch` folds its options into the one before it. `Stop` is not queued behind them: it starts (or joins) a native stop at once and drops every queued `Launch`/`Relaunch`. A queued Delete is never dropped, and a Delete does not cancel `Launch`/`Relaunch` requests queued before it: they run first. +- **iOS**: only stops and the delete's wipe are serialized, one native operation at a time. A `Launch`/`Relaunch` requested while a stop or a wipe is in flight is deferred until the SDK reports it finished. A later `Launch`/`Relaunch` replaces a deferred one, and a later `Stop` or `DeleteCollectedDataOnDevice` cancels it (it completes `false`). A Delete is never skipped because a `Launch` follows it: that `Launch` waits and runs after the wipe. + +What each call guarantees: + +- **Stop**: its completion runs when the SDK has reported the stop finished, or at the soft deadline. A `Launch`/`Relaunch` requested during the stop waits for it, and the completion runs before that `Launch` does. If the completion itself calls `Launch`, that later request replaces the waiting one (it completes `false`) and is the one that starts. On iOS the completion is dropped only if the call never ran (the 5 s hand-off) or an earlier completion of the same stop already launched; `StopAsync` completes either way. To change the token or options of a running SDK, call `Stop` and launch from its completion. +- **Launch / Relaunch**: starts a session when the SDK accepts it. A Launch while the SDK is already running ignores the new token and native options. On Android it also leaves the managed capture hooks unchanged; on iOS the managed capture options are applied again, so a repeated `Launch` with different capture options can change (or switch off) managed capture of a running session. Android ignores a `Relaunch` unless the SDK is `Launched`. +- **DeleteCollectedDataOnDevice**: stops the SDK if needed (joining a stop in flight), then deletes the data collected on the device, including finished report bundles, and leaves the SDK stopped: call `Launch` again to resume. It has no result. A failure is only logged: on iOS a warning when the SDK reports the wipe failed or had not finished stopping, on Android a warning when the native delete fails or does not answer in time. + +Async variants: + +- `LaunchAsync` / `RelaunchAsync` return `Task`: `true` when the SDK accepted the launch, `false` when it refused it or a later request cancelled it (which requests cancel a waiting launch is the per-platform ordering above). If the SDK gives no answer within 10 s, they complete `true` when its status is `Launching`/`Launched` and `false` otherwise. +- `StopAsync` returns `Task` and completes when the SDK has reported the stop, even if a Launch follows. It faults with `TimeoutException` when the SDK has not reported the stop within 10 s of that call (a call that joins a stop in flight has its own 10 s), and on iOS also when the main thread did not take the call within 5 s. On Android the Task stays pending if the Unity main thread is stalled, because the 10 s deadline is driven from the main thread. + +Deadlines, for an SDK that does not report back: + +- **10 s (soft)**, from when the call started: the caller is answered with a warning, and nothing else is released. A `Stop` completion runs, `StopAsync` faults, and on Android an in-flight `Launch`/`Relaunch`/`Delete` answers its internal completion. The native operation stays in flight: a `Launch`/`Relaunch` requested meanwhile still waits for it, and the SDK may still finish it. On Android that includes a delete already handed to native, which can complete after the 10 s warning. +- **60 s (hard)**: the wrapper stops waiting for a stop and moves on, with a warning. On iOS this also covers a wipe; on Android also an in-flight launch, relaunch or delete. A late report from the SDK is ignored by token. An iOS `Launch`/`Relaunch` already handed to the SDK has no hard deadline, only the 10 s status answer. + +Residual races after the hard deadline (the native SDKs cannot cancel an operation or report a refusal, so these are accepted): + +- **iOS**: a deferred `Launch` is released at the give-up, so if the native stop is in fact still running it can stop a `Launch` made after the hard deadline. A wipe still running when its deferred `Launch` is released overlaps that launch in the same way. +- **Android**: a late `Launch`/`Relaunch` result arriving after the reset is ignored when no stop intent is set (no `Stop` requested and no `Delete` started since), so the managed capture hooks can be out of step with that session until the next lifecycle call. With a stop intent set, a session started by a late result is stopped again. +- **Android**: after the reset of a delete, a queued `Launch` can run while the native delete is still wiping. A `Delete` started after the reset of a relaunch can overlap a relaunch that completes later still. + ## Crash & exception capture | Source | API / option | Severity default | diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 31f325c..82816f8 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -52,6 +52,11 @@ static IBugseeNativeBridge CreateBridge() /// Launch Bugsee. Pass typed / /// / (implicit /// conversion), a , or omit for native defaults. + /// Does not wait for the SDK to finish launching; on iOS, off the Unity main thread, it waits up to 5 s for + /// the main thread to take the call and does not run if that times out (logged). A Launch made while a stop + /// is in flight waits for it, and a Launch while the SDK is already running ignores the new token and + /// native options (stop first; on iOS the managed capture options are still applied again). Ordering rules + /// differ per platform: see the lifecycle section of the documentation. /// public static void Launch(string appToken, Dictionary options = null) { @@ -62,22 +67,35 @@ public static void Launch(string appToken, Dictionary options = Bridge.Launch(appToken, options); } + /// + /// Relaunch the SDK with new options. Does not wait for the SDK, and waits for a stop in flight like + /// (the same 5 s main-thread hand-off applies on iOS). Android ignores it with a + /// warning unless the SDK is Launched. See for the result. + /// public static void Relaunch(Dictionary options = null) => Bridge.Relaunch(options); /// - /// Stop Bugsee. runs once the SDK has finished stopping. It does not run if - /// a Launch/Relaunch starts a new session first, or if the stop itself did not run (on iOS, when called - /// off the main thread and the Unity main thread did not take it within 5 s; an error is logged). Use - /// to be told about that failure. + /// Stop Bugsee. runs once the SDK has finished stopping. A Launch/Relaunch + /// requested during the stop waits for it, and the completion runs before that Launch does. If the + /// completion itself calls Launch, that later request replaces the waiting one (it completes false) and is + /// the one that starts. On iOS the completion is dropped if the stop itself did not run (called off the main thread and the Unity main thread + /// did not take it within 5 s; an error is logged), or if an earlier completion of the same stop already + /// launched. Use to be told about the first case. If the SDK has not reported the stop within 10 s of + /// the call, the completion runs anyway with a warning (a Launch still waits for the stop); after 60 s the + /// wrapper stops waiting for the stop. Residual race after that: on iOS a late native stop can still stop a + /// Launch made after the 60 s give-up. /// public static void Stop(Action completion = null) => Bridge.Stop(completion); /// /// that completes with the SDK's answer: true when it accepted the launch, false - /// when it refused it or the launch never ran (a later Launch, Relaunch, Stop or Delete cancelled it while - /// it waited for a stop in flight). Never blocks the calling thread; awaited from the Unity main thread, - /// the continuation resumes there. + /// when it refused it or the launch never ran (a later request cancelled it while it waited + /// for a stop in flight; which requests do, differs per platform, see the lifecycle section of the + /// documentation). If the SDK gives no answer within 10 s the result is true when its status + /// is Launching/Launched and false otherwise. Does not wait for the SDK; on iOS, off the Unity main thread, it + /// waits up to 5 s for the main thread to take the call (false if it does not). Awaited from the Unity main + /// thread, the continuation resumes there. /// public static Task LaunchAsync(string appToken, Dictionary options = null) { @@ -165,6 +183,14 @@ public static void Log(string message, LogLevel level = LogLevel.Info) => public static void AddNetworkEvent(INetworkEvent networkEvent) => Bridge.AddNetworkEvent(networkEvent); + /// + /// Delete the data collected on the device, including finished report bundles. Stops the SDK first if it + /// is not stopped (joining a stop in flight) and leaves it stopped: Launch again to resume. It has no + /// result; a failure is logged. On Android it runs in request order with Launch/Relaunch (ones requested + /// earlier run first); on iOS a Launch/Relaunch requested after it waits for the wipe. A delete already + /// handed to the native SDK can finish after the 10 s warning, and after the 60 s hard deadline an Android + /// Launch can run while a native delete is still wiping. + /// public static void DeleteCollectedDataOnDevice() => Bridge.DeleteCollectedDataOnDevice(); From 198ba9b74bdaca69d2c7f359987b7f3ce66caa0e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 23:35:16 +0500 Subject: [PATCH 111/157] Fix wrapped-call Bugsee declaration detection and filtered mavenCentral in the Gradle injector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks enclosing calls across lines so wrapped exclude/compileOnly/test declarations are not taken for project declarations (which also removed an earlier injection); counts mavenCentral only when unfiltered or when its content filter includes com.bugsee. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + DESIGN.md | 2 +- Runtime/WrapperPolicy/GradleMavenInjection.cs | 846 ++++++++++++++++-- Tests~/WrapperPolicy/GradleKotlinDslTests.cs | 491 ++++++++++ 4 files changed, 1277 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7477424..b8da8a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. - iOS appearance setters and getters were a no-op because `+[Bugsee getAppearance]` returns a `BugseeTheme`, not a `BGSAppearance`. They now map onto `BugseeTheme` (report/feedback colors, report placeholders). Properties with no iOS equivalent are skipped and read back null: Report `ActionBarTextColor`, `ActionBarButtonBackgroundClickedColor`, `SeverityLabelActiveColor`; Feedback `ActionBarButtonBackgroundClickedColor`, `DateTextColor`, `EmailSkipBackgroundClickedColor`, `EmailContinueClickedColor`, `InputTextHintColor`, `BottomDelimiterColor`, `LoadingBarBackgroundColor`, `LoadingTextColor`, `ErrorTextColor`, `VersionChangedBackgroundColor`, `VersionChangedTextColor`; `Notification::Title`. diff --git a/DESIGN.md b/DESIGN.md index c7adbfe..66af7dc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -89,7 +89,7 @@ Property / key naming currently follows **Android 7.0**. When the Bugsee **iOS** ## Native packaging (already scaffolded) - `package.json` → `com.bugsee.unity`, no package dependencies -- `Editor/BugseeAndroidMavenDependencies.cs` → declares Maven 7.4.1 (+ NDK, both explicit) in the generated Gradle project; text edits in `Runtime/WrapperPolicy/GradleMavenInjection.cs`. A project-declared Bugsee dependency (EDM4U, custom template, local AAR) is kept and a version mismatch warned about; `.gradle.kts` files (project-converted; Unity generates Groovy) are edited in Kotlin syntax and `repositoriesMode` (`FAIL_ON_PROJECT_REPOS`) is respected +- `Editor/BugseeAndroidMavenDependencies.cs` → declares Maven 7.4.1 (+ NDK, both explicit) in the generated Gradle project; text edits in `Runtime/WrapperPolicy/GradleMavenInjection.cs`. A project-declared Bugsee dependency (EDM4U, custom template, local AAR) is kept and a version mismatch warned about; `.gradle.kts` files (project-converted; Unity generates Groovy) are edited in Kotlin syntax and `repositoriesMode` (`FAIL_ON_PROJECT_REPOS`) is respected; a project declaration is only a direct runtime-configuration argument in a top-level `dependencies {}` (never compileOnly/test/exclude/platform/constraints/variables), and a filtered `mavenCentral { content {} }` counts only if its filter serves `com.bugsee`. `maven { url content {} }` and `exclusiveContent` filters are read too. A declaration nested in an `if`/`else`/`with` block suppresses injection but never removes an earlier one. Not interpreted: version-catalog accessors, flavor-prefixed configurations - `Editor/BugseeIosSpmPostProcess.cs` → remote SPM `github.com/bugsee/spm` `7.0.0-beta6` - `Plugins/iOS/BugseeUnityBridge.mm` + `Plugins/Android/UnityManagedException.java` - `Tools~/scripts/update-native-sdks.sh` + `versions.env` diff --git a/Runtime/WrapperPolicy/GradleMavenInjection.cs b/Runtime/WrapperPolicy/GradleMavenInjection.cs index e91c2b9..c4ce451 100644 --- a/Runtime/WrapperPolicy/GradleMavenInjection.cs +++ b/Runtime/WrapperPolicy/GradleMavenInjection.cs @@ -132,7 +132,18 @@ public static DependencyResult ApplyDependencies( bool trailingNewline = buildGradle.EndsWith("\n", StringComparison.Ordinal); bool hasRegion = TryFindDependencyRegion(lines, out int regionStart, out int regionEnd); - var foreign = FindProjectDeclarations(lines, hasRegion ? regionStart : -1, hasRegion ? regionEnd : -1); + var foreign = FindProjectDeclarations(lines, hasRegion ? regionStart : -1, hasRegion ? regionEnd : -1, group); + + bool hasDirect = foreign.Exists(d => !d.Nested); + if (foreign.Count > 0 && !hasDirect) + { + // Declared only inside if/else/with blocks: do not inject a duplicate or override the project's + // condition, but a block we cannot evaluate is never a reason to delete an earlier injection. + result.Status = DependencyStatus.DeclaredByProject; + ReportProjectDeclarations(result, foreign, version); + result.Text = buildGradle; + return result; + } if (foreign.Count > 0) { @@ -231,12 +242,25 @@ public static RepositoryStatus EnsureMavenCentral(string gradle, string outerBlo } int reposClose = FindBlockClose(lines, reposOpen); - for (int i = reposOpen + 1; i < reposClose; i++) + if (BlockDeclaresMavenCentral(lines, reposOpen, reposClose)) { - if (DeclaresMavenCentral(lines[i])) + return RepositoryStatus.AlreadyPresent; + } + + if (reposOpen == reposClose) + { + // `repositories { google() }` on one line: a new line would be a sibling statement, and a + // line comment would swallow the closing brace, so declare it inside the braces. + int word = lines[reposOpen].IndexOf("repositories", StringComparison.Ordinal); + int brace = word < 0 ? -1 : lines[reposOpen].IndexOf('{', word); + if (brace < 0) { - return RepositoryStatus.AlreadyPresent; + return RepositoryStatus.BlockNotFound; } + + lines[reposOpen] = lines[reposOpen].Insert(brace + 1, " mavenCentral();"); + updated = JoinLines(lines, newline, gradle.EndsWith("\n", StringComparison.Ordinal)); + return RepositoryStatus.Inserted; } string indent = LeadingWhitespace(lines[reposOpen]) + " "; @@ -429,113 +453,585 @@ sealed class Declaration public bool IsNdk; public string Version; public string Line; + + /// Found inside an if/else/with/for/while block: enough to skip injecting, never to remove an earlier injection. + public bool Nested; } - static List FindProjectDeclarations(List lines, int skipStart, int skipEnd) + /// + /// The Bugsee declarations a project makes itself, found in one pass over the file. Only a statement + /// directly inside a top-level dependencies { } block counts, and only when its configuration is + /// implementation, api, runtimeOnly, compile or runtime (or the + /// debug / release prefixed Implementation/Api/RuntimeOnly; other flavor + /// prefixes are not recognised, which at worst injects a duplicate of the same coordinate) and the Bugsee + /// coordinate is a direct argument of it: a coordinate string, a group/name map pair, a map + /// name naming a local AAR, or files('...bugsee-android...aar'). Anything else (other + /// configurations, exclude, platform, fileTree, constraints, + /// resolutionStrategy, variables, println) is not a declaration, because an earlier injection is + /// removed when one is found. + /// + static List FindProjectDeclarations(List lines, int skipStart, int skipEnd, string group) { var found = new List(); - var inBlockComment = new LexState(); + var lex = new LexState(); + var text = new StringBuilder(); for (int i = 0; i < lines.Count; i++) { - string code = StripComments(lines[i], ref inBlockComment); - if (i >= skipStart && i <= skipEnd) + string code = StripComments(lines[i], ref lex); + text.Append(i >= skipStart && i <= skipEnd ? string.Empty : code).Append('\n'); + } + + string src = text.ToString(); + int depth = 0; + bool inDeps = false; + var statement = new StringBuilder(); + char quote = '\0'; + for (int i = 0; i < src.Length; i++) + { + char c = src[i]; + if (quote != '\0') { + if (c == '\n') + { + quote = '\0'; + if (inDeps) + { + statement.Append(c); + } + + continue; + } + + if (inDeps) + { + statement.Append(c); + } + + if (c == '\\' && i + 1 < src.Length && src[i + 1] != '\n') + { + i++; + if (inDeps) + { + statement.Append(src[i]); + } + } + else if (c == quote) + { + quote = '\0'; + } + continue; } - int search = 0; - while (search < code.Length) + if (c == '\n') { - int at = code.IndexOf(CoreArtifactToken, search, StringComparison.Ordinal); - if (at < 0) + if (inDeps && depth == 1) { - break; + string pending = statement.ToString().TrimEnd(); + if (pending.EndsWith(",", StringComparison.Ordinal) || + pending.EndsWith("\\", StringComparison.Ordinal)) + { + statement.Append(c); + } + else + { + FlushStatement(statement, group, found); + } } + else if (inDeps) + { + statement.Append(c); + } + + continue; + } - search = at + CoreArtifactToken.Length; - if (at > 0 && IsArtifactChar(code[at - 1])) + if (c == '\'' || c == '"') + { + quote = c; + if (inDeps) { - continue; + statement.Append(c); + } + } + else if (c == '(' || c == '{' || c == '[') + { + if (!inDeps && depth == 0 && c == '{' && HeadIdentifier(src, i) == "dependencies") + { + inDeps = true; + statement.Clear(); + } + else if (inDeps) + { + statement.Append(c); } - var declaration = Classify(code, at); - if (declaration != null && !IsOtherConfiguration(code.Substring(0, at))) + depth++; + } + else if (c == ')' || c == '}' || c == ']') + { + depth = Math.Max(0, depth - 1); + if (inDeps && depth == 0) { - declaration.Line = lines[i].Trim(); - found.Add(declaration); + FlushStatement(statement, group, found); + inDeps = false; + } + else if (inDeps) + { + statement.Append(c); } } + else if (c == ';' && inDeps && depth == 1) + { + FlushStatement(statement, group, found); + } + else if (inDeps && !(statement.Length == 0 && char.IsWhiteSpace(c))) + { + statement.Append(c); + } } return found; } + static string HeadIdentifier(string code, int opener) + { + int k = opener - 1; + while (k >= 0 && char.IsWhiteSpace(code[k])) + { + k--; + } + + int end = k + 1; + while (k >= 0 && (char.IsLetterOrDigit(code[k]) || code[k] == '_')) + { + k--; + } + + return code.Substring(k + 1, end - k - 1); + } + + static void FlushStatement(StringBuilder statement, string group, List found) + { + string text = statement.ToString().Trim(); + statement.Clear(); + if (text.Length == 0) + { + return; + } + + string display = Collapse(text); + var direct = ParseDeclarations(text, group); + foreach (var declaration in direct) + { + declaration.Line = display; + found.Add(declaration); + } + + if (direct.Count == 0) + { + CollectNested(text, group, display, found, 0); + } + } + + /// A statement as one short line for messages: whitespace collapsed, long text cut. + static string Collapse(string statement) + { + string text = System.Text.RegularExpressions.Regex.Replace(statement.Trim(), @"\s+", " "); + return text.Length <= 160 ? text : text.Substring(0, 157) + "..."; + } + + static bool IsControlFlow(string statement) + { + string name = ReadIdentifier(statement, 0, out _); + return name == "if" || name == "else" || name == "with" || name == "for" || name == "while"; + } + + static string ReadIdentifier(string text, int start, out int end) + { + end = start; + while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_')) + { + end++; + } + + return text.Substring(start, end - start); + } + /// - /// True when the call or configuration the coordinate belongs to is one that does not put the SDK on the - /// app's runtime classpath: exclude, compileOnly*, test configurations (testImplementation, - /// androidTestImplementation, ...) and annotation-processor configurations. - /// is the code of the line up to the coordinate. A coordinate with no call before it (a continuation line) - /// counts as a declaration. + /// Declarations inside the body of an if/else/with/for/while statement, found with the same strict + /// rules as top-level ones but marked . /// - static bool IsOtherConfiguration(string before) + static void CollectNested(string statement, string group, string display, List found, int level) { - // Drop string contents (they hold group names such as "com.bugsee") and the keys of map notation. - var sb = new StringBuilder(); + if (level > 8 || !IsControlFlow(statement)) + { + return; + } + + string t = statement.TrimStart(); + string name = ReadIdentifier(t, 0, out int i); + if (name == "else") + { + int j = SkipWhitespace(t, i); + if (ReadIdentifier(t, j, out int afterIf) == "if") + { + i = afterIf; + } + } + + i = SkipWhitespace(t, i); + if (i < t.Length && t[i] == '(') + { + int close = MatchingClose(t, i); + if (close < 0) + { + return; + } + + i = SkipWhitespace(t, close + 1); + } + + string body; + if (i < t.Length && t[i] == '{') + { + int close = MatchingClose(t, i); + body = close < 0 ? t.Substring(i + 1) : t.Substring(i + 1, close - i - 1); + if (close >= 0 && t.Substring(close + 1).TrimStart().StartsWith("else", StringComparison.Ordinal)) + { + CollectNested(t.Substring(close + 1).TrimStart(), group, display, found, level + 1); + } + } + else + { + body = t.Substring(i); + } + + foreach (string inner in SplitStatements(body)) + { + var declarations = ParseDeclarations(inner.Trim(), group); + foreach (var declaration in declarations) + { + declaration.Line = Collapse(inner); + declaration.Nested = true; + found.Add(declaration); + } + + if (declarations.Count == 0) + { + CollectNested(inner.Trim(), group, display, found, level + 1); + } + } + } + + /// Splits at newlines and semicolons outside strings and brackets; a trailing comma or backslash continues the statement. + static List SplitStatements(string body) + { + var parts = new List(); + var current = new StringBuilder(); + int depth = 0; char quote = '\0'; - foreach (char c in before) + for (int i = 0; i < body.Length; i++) { + char c = body[i]; if (quote != '\0') { - if (c == quote) + if (c == '\n') + { + quote = '\0'; + } + else if (c == '\\' && i + 1 < body.Length) + { + current.Append(c); + c = body[++i]; + } + else if (c == quote) { quote = '\0'; } + current.Append(c); continue; } if (c == '\'' || c == '"') { quote = c; - sb.Append(' '); + } + else if (c == '(' || c == '[' || c == '{') + { + depth++; + } + else if (c == ')' || c == ']' || c == '}') + { + depth = Math.Max(0, depth - 1); + } + else if (depth == 0 && (c == '\n' || c == ';')) + { + string pending = current.ToString().TrimEnd(); + if (c == '\n' && (pending.EndsWith(",", StringComparison.Ordinal) || + pending.EndsWith("\\", StringComparison.Ordinal))) + { + current.Append(c); + continue; + } + + if (pending.Length > 0) + { + parts.Add(pending); + } + + current.Clear(); continue; } - sb.Append(c); + current.Append(c); } - string name = null; - foreach (System.Text.RegularExpressions.Match m in - System.Text.RegularExpressions.Regex.Matches(sb.ToString(), @"[A-Za-z_][A-Za-z0-9_]*")) + if (current.ToString().Trim().Length > 0) + { + parts.Add(current.ToString()); + } + + return parts; + } + + static bool IsRuntimeConfiguration(string name) + { + switch (name) + { + case "implementation": + case "api": + case "runtimeOnly": + case "compile": + case "runtime": + case "debugImplementation": + case "releaseImplementation": + case "debugApi": + case "releaseApi": + case "debugRuntimeOnly": + case "releaseRuntimeOnly": + return true; + default: + return false; + } + } + + static List ParseDeclarations(string statement, string group) + { + var result = new List(); + int p = 0; + while (p < statement.Length && (char.IsLetterOrDigit(statement[p]) || statement[p] == '_')) + { + p++; + } + + if (p == 0 || !IsRuntimeConfiguration(statement.Substring(0, p))) + { + return result; + } + + if (p < statement.Length && !char.IsWhiteSpace(statement[p]) && statement[p] != '(' && + statement[p] != '\'' && statement[p] != '"') + { + return result; + } + + string rest = statement.Substring(p).TrimStart(); + string args; + if (rest.StartsWith("(", StringComparison.Ordinal)) + { + int close = MatchingClose(rest, 0); + args = close < 0 ? rest.Substring(1) : rest.Substring(1, close - 1); + } + else { - switch (m.Value) + args = rest; + } + + string mapGroup = null; + string mapName = null; + foreach (string raw in SplitTopLevel(args)) + { + string arg = raw.Trim(); + string literal = AsLiteral(arg); + if (literal != null) { - case "group": - case "name": - case "module": - case "version": - case "ext": - case "classifier": - continue; + string[] parts = literal.Split(':'); + if (parts.Length >= 2 && parts[0] == group) + { + string artifact = parts[1] + (parts.Length > 2 ? ":" + parts[2].Split('@')[0] : string.Empty); + AddIfBugsee(result, artifact); + } + + continue; + } + + var pair = System.Text.RegularExpressions.Regex.Match(arg, @"^(group|name)\s*[:=]\s*(.+)$", System.Text.RegularExpressions.RegexOptions.Singleline); + if (pair.Success) + { + string value = AsLiteral(pair.Groups[2].Value.Trim()); + if (value != null) + { + if (pair.Groups[1].Value == "group") + { + mapGroup = value; + } + else + { + mapName = value; + } + } + + continue; } - name = m.Value; + var files = System.Text.RegularExpressions.Regex.Match(arg, @"^files\s*\((.*)\)$", System.Text.RegularExpressions.RegexOptions.Singleline); + if (files.Success) + { + foreach (string file in SplitTopLevel(files.Groups[1].Value)) + { + string path = AsLiteral(file.Trim()); + if (path == null || !path.EndsWith(".aar", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + AddIfBugsee(result, path.Substring(path.LastIndexOfAny(new[] { '/', '\\' }) + 1)); + } + } } - if (name == null) + if (mapName != null && (mapGroup == null || mapGroup == group)) { - return false; + AddIfBugsee(result, mapName); } - string lower = name.ToLowerInvariant(); - return lower == "exclude" || - lower.Contains("compileonly") || - lower.StartsWith("test", StringComparison.Ordinal) || - lower.StartsWith("androidtest", StringComparison.Ordinal) || - lower.StartsWith("unittest", StringComparison.Ordinal) || - lower.Contains("annotationprocessor") || - lower.StartsWith("kapt", StringComparison.Ordinal) || - lower.StartsWith("ksp", StringComparison.Ordinal); + return result; + } + + static void AddIfBugsee(List result, string artifactAndVersion) + { + if (!artifactAndVersion.StartsWith(CoreArtifactToken, StringComparison.Ordinal)) + { + return; + } + + var declaration = Classify(artifactAndVersion, 0); + if (declaration != null) + { + result.Add(declaration); + } + } + + /// The content of when it is exactly one quoted string literal, else null. + static string AsLiteral(string arg) + { + if (arg.Length < 2 || (arg[0] != '\'' && arg[0] != '"') || arg[arg.Length - 1] != arg[0]) + { + return null; + } + + string inner = arg.Substring(1, arg.Length - 2); + return inner.IndexOf(arg[0]) >= 0 ? null : inner; + } + + /// Index of the bracket closing the one at (strings skipped), or -1. + static int MatchingClose(string s, int open) + { + int depth = 0; + char quote = '\0'; + for (int i = open; i < s.Length; i++) + { + char c = s[i]; + if (quote != '\0') + { + if (c == '\\') + { + i++; + } + else if (c == quote) + { + quote = '\0'; + } + + continue; + } + + if (c == '\'' || c == '"') + { + quote = c; + } + else if (c == '(' || c == '[' || c == '{') + { + depth++; + } + else if (c == ')' || c == ']' || c == '}') + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } + + /// Splits at commas outside strings and brackets; a top-level { (a trailing closure) ends the list. + static List SplitTopLevel(string args) + { + var parts = new List(); + var current = new StringBuilder(); + int depth = 0; + char quote = '\0'; + for (int i = 0; i < args.Length; i++) + { + char c = args[i]; + if (quote != '\0') + { + current.Append(c); + if (c == '\\' && i + 1 < args.Length) + { + current.Append(args[++i]); + } + else if (c == quote) + { + quote = '\0'; + } + + continue; + } + + if (c == '\'' || c == '"') + { + quote = c; + } + else if (c == '{' && depth == 0) + { + break; + } + else if (c == '(' || c == '[' || c == '{') + { + depth++; + } + else if (c == ')' || c == ']' || c == '}') + { + depth--; + } + else if (c == ',' && depth == 0) + { + parts.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(c); + } + + parts.Add(current.ToString()); + return parts; } /// @@ -607,7 +1103,7 @@ static void ReportProjectDeclarations(DependencyResult result, List hasNdk |= declaration.IsNdk; if (declaration.Version == null || string.Equals(declaration.Version, version, StringComparison.Ordinal) || - !reported.Add(declaration.Line)) + !reported.Add(declaration.Line + "|" + declaration.IsNdk)) { continue; } @@ -633,13 +1129,239 @@ static void ReportProjectDeclarations(DependencyResult result, List } } - static bool DeclaresMavenCentral(string line) + /// + /// True when a repositories block declares a Maven Central that serves the Bugsee artifacts: an unfiltered + /// mavenCentral(), or a mavenCentral { content { ... } } whose include filters cover + /// com.bugsee:bugsee-android and bugsee-android-ndk and whose exclude filters do not touch + /// them. A filter that cannot be read (version filters, unparsable regex) counts as not serving them, and the + /// caller then adds the marked unfiltered mavenCentral() (Gradle unions repositories). + /// + static bool BlockDeclaresMavenCentral(List lines, int open, int close) { - var inBlockComment = new LexState(); - string code = StripComments(line, ref inBlockComment); - return System.Text.RegularExpressions.Regex.IsMatch(code, @"\bmavenCentral\s*[({]") || - code.IndexOf("repo.maven.apache.org/maven2", StringComparison.Ordinal) >= 0 || - code.IndexOf("repo1.maven.org/maven2", StringComparison.Ordinal) >= 0; + var lex = new LexState(); + var text = new StringBuilder(); + for (int i = open; i <= close; i++) + { + text.Append(StripComments(lines[i], ref lex)).Append('\n'); + } + + // The text between the braces of the repositories block, so a one-line block or a + // `repositories { mavenCentral()` first line is scanned too. + string whole = text.ToString(); + string wholeMasked = MaskStrings(whole); + int braceOpen = wholeMasked.IndexOf('{'); + if (braceOpen < 0) + { + return false; + } + + int braceClose = MatchingClose(wholeMasked, braceOpen); + string block = braceClose < 0 ? whole.Substring(braceOpen + 1) : whole.Substring(braceOpen + 1, braceClose - braceOpen - 1); + string masked = MaskStrings(block); + + // exclusiveContent { forRepository { mavenCentral() } filter { ... } }: the filter decides. + var exclusive = new List(); + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(masked, @"\bexclusiveContent\s*\{")) + { + int openAt = m.Index + m.Length - 1; + int closeAt = MatchingClose(masked, openAt); + exclusive.Add(new[] { openAt, closeAt < 0 ? block.Length : closeAt }); + } + + // maven { url 'https://repo.maven.apache.org/maven2' [content { ... }] } + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(masked, @"\bmaven\s*\{")) + { + int openAt = m.Index + m.Length - 1; + int closeAt = MatchingClose(masked, openAt); + string body = closeAt < 0 ? block.Substring(openAt + 1) : block.Substring(openAt + 1, closeAt - openAt - 1); + if ((body.IndexOf("repo.maven.apache.org/maven2", StringComparison.Ordinal) >= 0 || + body.IndexOf("repo1.maven.org/maven2", StringComparison.Ordinal) >= 0) && + ContentFilterServesBugsee(body, "content") && + ExclusiveFiltersServe(block, exclusive, m.Index)) + { + return true; + } + } + + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(masked, @"\bmavenCentral\b")) + { + int p = SkipWhitespace(block, m.Index + m.Length); + if (p < block.Length && block[p] == '(') + { + int closeParen = MatchingClose(block, p); + if (closeParen < 0 || block.Substring(p + 1, closeParen - p - 1).Trim().Length > 0) + { + continue; + } + + p = SkipWhitespace(block, closeParen + 1); + } + + bool served = true; + if (p < block.Length && block[p] == '{') + { + int closeBrace = MatchingClose(masked, p); + string body = closeBrace < 0 ? block.Substring(p + 1) : block.Substring(p + 1, closeBrace - p - 1); + served = ContentFilterServesBugsee(body, "content"); + } + + served &= ExclusiveFiltersServe(block, exclusive, m.Index); + if (served) + { + return true; + } + } + + return false; + } + + /// True unless the repository at sits in an exclusiveContent whose filter does not serve Bugsee. + static bool ExclusiveFiltersServe(string block, List exclusive, int index) + { + foreach (int[] span in exclusive) + { + if (index > span[0] && index < span[1] && + !ContentFilterServesBugsee( + block.Substring(span[0] + 1, span[1] - span[0] - 1), "filter", requireFilter: true)) + { + return false; + } + } + + return true; + } + + /// The text with the contents of string literals blanked, so names inside strings are not matched. + static string MaskStrings(string code) + { + var sb = new StringBuilder(code.Length); + char quote = '\0'; + for (int i = 0; i < code.Length; i++) + { + char c = code[i]; + if (quote != '\0') + { + if (c == '\\' && i + 1 < code.Length) + { + sb.Append(" "); + i++; + } + else if (c == quote || c == '\n') + { + quote = '\0'; + sb.Append(c); + } + else + { + sb.Append(' '); + } + + continue; + } + + if (c == '\'' || c == '"') + { + quote = c; + } + + sb.Append(c); + } + + return sb.ToString(); + } + + static int SkipWhitespace(string s, int p) + { + while (p < s.Length && char.IsWhiteSpace(s[p])) + { + p++; + } + + return p; + } + + static bool ContentFilterServesBugsee(string body, string filterName, bool requireFilter = false) + { + var content = System.Text.RegularExpressions.Regex.Match(body, @"\b" + filterName + @"\s*\{"); + if (!content.Success) + { + return !requireFilter; + } + + int open = content.Index + content.Length - 1; + int closeBrace = MatchingClose(body, open); + string filter = closeBrace < 0 ? body.Substring(open + 1) : body.Substring(open + 1, closeBrace - open - 1); + + bool anyInclude = false; + bool coversCore = false; + bool coversNdk = false; + foreach (System.Text.RegularExpressions.Match call in System.Text.RegularExpressions.Regex.Matches( + filter, + @"\b(include|exclude)(Group|GroupAndSubgroups|GroupByRegex|Module|ModuleByRegex|Version|VersionByRegex)\s*\(?\s*(['""])(.*?)\3(?:\s*,\s*(['""])(.*?)\5)?")) + { + bool include = call.Groups[1].Value == "include"; + string kind = call.Groups[2].Value; + string first = call.Groups[4].Value.Replace("\\\\", "\\"); + string second = call.Groups[6].Success ? call.Groups[6].Value.Replace("\\\\", "\\") : null; + if (kind.StartsWith("Version", StringComparison.Ordinal)) + { + return false; + } + + bool core = FilterMatches(kind, first, second, CoreArtifactToken); + bool ndk = FilterMatches(kind, first, second, NdkArtifactToken); + if (!include) + { + if (core || ndk) + { + return false; + } + + continue; + } + + anyInclude = true; + coversCore |= core; + coversNdk |= ndk; + } + + return !anyInclude || (coversCore && coversNdk); + } + + /// True when a content-filter call (kind, first and optional second string) matches the Bugsee group and ; an unparsable regex matches. + static bool FilterMatches(string kind, string first, string second, string artifact) + { + const string bugseeGroup = "com.bugsee"; + switch (kind) + { + case "Group": + return first == bugseeGroup; + case "GroupAndSubgroups": + return bugseeGroup == first || bugseeGroup.StartsWith(first + ".", StringComparison.Ordinal); + case "GroupByRegex": + return RegexMatches(first, bugseeGroup); + case "Module": + return first == bugseeGroup && second == artifact; + case "ModuleByRegex": + return RegexMatches(first, bugseeGroup) && second != null && RegexMatches(second, artifact); + default: + return true; + } + } + + static bool RegexMatches(string pattern, string input) + { + try + { + return System.Text.RegularExpressions.Regex.IsMatch(input, "^(?:" + pattern + ")$"); + } + catch (ArgumentException) + { + return true; + } } /// Index of the line opening a depth-0 name { block, or -1. diff --git a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs index e0224d6..1290211 100644 --- a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs +++ b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs @@ -417,5 +417,496 @@ public void Multi_line_triple_quoted_strings_do_not_hide_or_fake_structure() Assert.That(result.Text.IndexOf("val notes"), Is.LessThan(result.Text.IndexOf("bugsee:maven-dependencies begin"))); Assert.That(result.Text, Does.StartWith(gradle.Substring(0, gradle.IndexOf("dependencies {\n implementation(fileTree")))); } + + static readonly string[] WrappedNonRuntime = + { + " implementation(\"com.example:lib:1\") { exclude(\n group = \"com.bugsee\",\n module = \"bugsee-android\"\n ) }", + " implementation(\"com.example:lib:1\") {\n exclude(\n group = \"com.bugsee\",\n module = \"bugsee-android\"\n )\n }", + " compileOnly(\n \"com.bugsee:bugsee-android:7.4.1\"\n )", + " testImplementation(\n group = \"com.bugsee\",\n name = \"bugsee-android-ndk\",\n version = \"7.4.1\"\n )", + " implementation('com.example:lib:1') {\n exclude group: 'com.bugsee',\n module: 'bugsee-android'\n }", + " compileOnly(\n 'com.bugsee:bugsee-android:7.4.1'\n )", + }; + + [TestCaseSource(nameof(WrappedNonRuntime))] + public void Wrapped_exclusions_and_other_configurations_are_not_project_declarations(string declaration) + { + string gradle = "dependencies {\n" + declaration + "\n}\n"; + + Assert.That(ApplyGroovy(gradle).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(ApplyKts(gradle).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + } + + [TestCaseSource(nameof(WrappedNonRuntime))] + public void A_previously_injected_region_survives_wrapped_non_runtime_mentions(string declaration) + { + string first = ApplyKts("dependencies {\n" + declaration + "\n}\n").Text; + string again = ApplyKts(first).Text; + + Assert.That(again, Is.EqualTo(first)); + Assert.That(again, Does.Contain("// bugsee:maven-dependencies begin")); + Assert.That(again, Does.Contain(KtsCore)); + } + + [Test] + public void Wrapped_runtime_declaration_is_a_project_declaration_and_replaces_the_injection() + { + string injected = ApplyKts("dependencies {\n}\n").Text; + string withProject = injected.Replace( + "dependencies {\n", + "dependencies {\n implementation(\n group = \"com.bugsee\",\n name = \"bugsee-android\",\n version = \"7.4.1\"\n )\n"); + var result = ApplyKts(withProject); + + Assert.That(result.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(result.Text, Does.Not.Contain("bugsee:maven-dependencies")); + + string wrappedString = "dependencies {\n implementation(\n \"com.bugsee:bugsee-android:7.4.1\"\n )\n}\n"; + Assert.That(ApplyKts(wrappedString).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(ApplyGroovy(wrappedString).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + } + + [Test] + public void Filtered_maven_central_that_excludes_the_bugsee_group_still_gets_an_unfiltered_one() + { + const string root = + "allprojects {\n" + + " repositories {\n" + + " mavenCentral {\n" + + " content { includeGroupByRegex(\"androidx.*\") }\n" + + " }\n" + + " }\n" + + "}\n"; + + var status = GradleMavenInjection.EnsureMavenCentral(root, "allprojects", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Does.Contain(" repositories {\n mavenCentral() // bugsee:maven-repository\n mavenCentral {")); + Assert.That( + GradleMavenInjection.EnsureMavenCentral(updated, "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + + [Test] + public void Plain_maven_central_next_to_a_filtered_one_counts() + { + const string root = + "allprojects {\n" + + " repositories {\n" + + " mavenCentral()\n" + + " google {\n content { includeGroup(\"androidx\") }\n }\n" + + " }\n" + + "}\n"; + + Assert.That( + GradleMavenInjection.EnsureMavenCentral(root, "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + + // Mentions of the SDK that are not a runtime declaration: an earlier injection must survive them. + static readonly string[] NotDeclarations = + { + "dependencies {\n compileOnly files('libs/bugsee-android-7.4.1.aar')\n}\n", + "dependencies {\n testImplementation platform('com.bugsee:bugsee-android:7.4.1')\n}\n", + "dependencies {\n implementation platform('com.bugsee:bugsee-android:7.4.1')\n}\n", + "dependencies {\n compileOnly fileTree(dir:'libs', include:['bugsee-android-7.4.1.aar'])\n}\n", + "dependencies {\n implementation(fileTree(mapOf(\"dir\" to \"libs\", \"exclude\" to listOf(\"bugsee-android-7.4.1.aar\"))))\n}\n", + "dependencies {\n constraints {\n implementation(\"com.bugsee:bugsee-android:7.4.1\")\n }\n}\n", + "val bugsee = \"com.bugsee:bugsee-android:7.4.1\"\ndependencies {\n compileOnly(bugsee)\n}\n", + "def bugsee = 'com.bugsee:bugsee-android:7.4.1'\ndependencies {\n compileOnly bugsee\n}\n", + "configurations.all {\n resolutionStrategy {\n force 'com.bugsee:bugsee-android:7.4.1'\n }\n}\ndependencies {\n}\n", + "dependencies {\n println 'bugsee-android'\n}\n", + "dependencies {\n fooImplementation 'com.bugsee:bugsee-android:7.4.1'\n}\n", + "dependencies {\n implementation 'org.example:lib:1', 'org.example:bugsee-android:1'\n}\n", + }; + + [TestCaseSource(nameof(NotDeclarations))] + public void Non_declaration_mentions_are_injected_next_to_and_never_remove_an_earlier_injection(string gradle) + { + foreach (bool kotlin in new[] { false, true }) + { + var first = kotlin ? ApplyKts(gradle) : ApplyGroovy(gradle); + Assert.That(first.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(first.Warnings, Is.Empty); + + var second = kotlin ? ApplyKts(first.Text) : ApplyGroovy(first.Text); + Assert.That(second.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.AlreadyInjected)); + Assert.That(second.Text, Is.EqualTo(first.Text)); + } + } + + [TestCase(" implementation files('libs/bugsee-android-7.3.0.aar')")] + [TestCase(" debugImplementation 'com.bugsee:bugsee-android:7.3.0'")] + [TestCase(" releaseImplementation(\"com.bugsee:bugsee-android:7.3.0\")")] + [TestCase(" implementation 'org.example:lib:1', 'com.bugsee:bugsee-android:7.3.0'")] + [TestCase(" implementation(\"com.bugsee:bugsee-android:7.3.0\") { exclude(group = \"x\") }")] + public void Direct_arguments_of_a_runtime_configuration_are_declarations(string declaration) + { + string gradle = "dependencies {\n" + declaration + "\n}\n"; + + Assert.That(ApplyGroovy(gradle).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(ApplyKts(gradle).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + } + + [Test] + public void Statement_scanning_is_linear_in_the_number_of_wrapped_lines() + { + var sb = new System.Text.StringBuilder("dependencies {\n"); + for (int i = 0; i < 1400; i++) + { + sb.Append(" implementation(\n \"com.example:lib").Append(i).Append(":1\"\n )\n"); + } + + sb.Append("}\n"); + string gradle = sb.ToString(); + Assert.That(gradle.Split('\n').Length, Is.GreaterThan(4000)); + + var watch = System.Diagnostics.Stopwatch.StartNew(); + var first = ApplyKts(gradle); + var second = ApplyKts(first.Text); + watch.Stop(); + + Assert.That(first.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(second.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.AlreadyInjected)); + Assert.That(watch.ElapsedMilliseconds, Is.LessThan(1000)); + } + + static string Repos(string mavenCentral) + { + return "allprojects {\n repositories {\n " + mavenCentral + "\n }\n}\n"; + } + + [TestCase("mavenCentral {\n content { excludeGroup(\"com.bugsee\") }\n }")] + [TestCase("mavenCentral { content { excludeGroup(\"com.bugsee\") } }")] + [TestCase("mavenCentral {\n content {\n excludeGroupAndSubgroups(\"com\")\n }\n }")] + [TestCase("mavenCentral { content { excludeGroupByRegex(\"com\\\\.bug.*\") } }")] + [TestCase("mavenCentral { content { includeGroup(\"com.bugsee.other\") } }")] + [TestCase("mavenCentral { content { includeGroup(\"androidx.core\") } }")] + [TestCase("mavenCentral { content { includeGroup(\"com.bugsee\"); excludeGroup(\"com.bugsee\") } }")] + [TestCase("mavenCentral { content { includeModule(\"com.bugsee\", \"bugsee-android\") } }")] + [TestCase("mavenCentral { content { includeVersion(\"com.bugsee\", \"bugsee-android\", \"7.4.1\") } }")] + [TestCase("mavenCentral() { content { includeGroup(\"androidx.core\") } }")] + public void Filters_that_do_not_serve_the_bugsee_artifacts_get_an_unfiltered_repository(string repository) + { + var status = GradleMavenInjection.EnsureMavenCentral(Repos(repository), "allprojects", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Does.Contain("mavenCentral() // bugsee:maven-repository")); + } + + [TestCase("mavenCentral()")] + [TestCase("mavenCentral() ; google()")] + [TestCase("mavenCentral() // plain")] + [TestCase("mavenCentral { }")] + [TestCase("mavenCentral { content { includeGroup(\"com.bugsee\") } }")] + [TestCase("mavenCentral { content { includeGroupByRegex(\"com\\\\.bugsee.*\") } }")] + [TestCase("mavenCentral { content { includeGroupAndSubgroups(\"com\") } }")] + [TestCase("mavenCentral { content { includeModule(\"com.bugsee\", \"bugsee-android\"); includeModule(\"com.bugsee\", \"bugsee-android-ndk\") } }")] + [TestCase("mavenCentral { content { excludeGroup(\"com.other\") } }")] + public void Filters_that_serve_the_bugsee_artifacts_count_as_declared(string repository) + { + Assert.That( + GradleMavenInjection.EnsureMavenCentral(Repos(repository), "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + + [TestCase("maven { url \"https://repo.example.com/artifactory/mavenCentral\" }")] + [TestCase("maven { name = \"mavenCentral\"; url = uri(\"https://repo.example.com/x\") }")] + [TestCase("maven {\n url 'https://repo.maven.apache.org/maven2'\n content { includeGroupByRegex(\"androidx.*\") }\n }")] + [TestCase("maven {\n url 'https://repo1.maven.org/maven2'\n content { excludeGroup(\"com.bugsee\") }\n }")] + [TestCase("exclusiveContent {\n forRepository { mavenCentral() }\n filter { includeGroupByRegex(\"androidx.*\") }\n }")] + [TestCase("exclusiveContent {\n forRepository { mavenCentral() }\n }")] + public void Look_alikes_and_filtered_repositories_get_an_unfiltered_one(string repository) + { + var status = GradleMavenInjection.EnsureMavenCentral(Repos(repository), "allprojects", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Does.Contain("mavenCentral() // bugsee:maven-repository")); + } + + [TestCase("maven { url 'https://repo.maven.apache.org/maven2' }")] + [TestCase("maven {\n url 'https://repo.maven.apache.org/maven2'\n content { includeGroup(\"com.bugsee\") }\n }")] + [TestCase("mavenCentral { url = uri(\"https://nexus.corp/repository/maven-public\") }")] + [TestCase("mavenCentral { metadataSources { gradleMetadata() } }")] + [TestCase("exclusiveContent {\n forRepository { mavenCentral() }\n filter { includeGroup(\"com.bugsee\") }\n }")] + [TestCase("mavenCentral()\n exclusiveContent {\n forRepository { maven { url \"https://private\" } }\n filter { includeGroup(\"com.private\") }\n }")] + public void Unfiltered_or_bugsee_serving_repositories_count_as_declared(string repository) + { + Assert.That( + GradleMavenInjection.EnsureMavenCentral(Repos(repository), "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + + [TestCase("exclusiveContent {\n forRepository { maven { url \"https://repo.maven.apache.org/maven2\" } }\n filter { includeGroupByRegex(\"androidx.*\") }\n }")] + [TestCase("exclusiveContent {\n forRepository { maven { url 'https://repo1.maven.org/maven2' } }\n }")] + public void Exclusive_content_filters_apply_to_central_by_url(string repository) + { + var status = GradleMavenInjection.EnsureMavenCentral(Repos(repository), "allprojects", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Does.Contain("mavenCentral() // bugsee:maven-repository")); + } + + [Test] + public void Unfiltered_central_by_url_and_serving_exclusive_content_stay_declared() + { + Assert.That( + GradleMavenInjection.EnsureMavenCentral( + Repos("maven { url \"https://repo.maven.apache.org/maven2\" }"), "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That( + GradleMavenInjection.EnsureMavenCentral( + Repos("exclusiveContent {\n forRepository { maven { url \"https://repo.maven.apache.org/maven2\" } }\n filter { includeGroup(\"com.bugsee\") }\n }"), + "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + + static readonly string[] ConditionalDeclarations = + { + "dependencies {\n if (project.hasProperty('enableBugsee')) {\n implementation 'com.bugsee:bugsee-android:7.3.0'\n implementation 'com.bugsee:bugsee-android-ndk:7.3.0'\n } else {\n implementation fileTree(dir: 'libs', include: ['*.jar'])\n }\n}\n", + "dependencies {\n if (hasProperty(\"enableBugsee\")) {\n implementation(\"com.bugsee:bugsee-android:7.3.0\")\n implementation(\"com.bugsee:bugsee-android-ndk:7.3.0\")\n }\n}\n", + "dependencies {\n if (cond) {\n } else if (other) {\n api 'com.bugsee:bugsee-android:7.3.0'\n api 'com.bugsee:bugsee-android-ndk:7.3.0'\n }\n}\n", + "dependencies {\n project.with {\n }\n with(project) {\n implementation 'com.bugsee:bugsee-android:7.3.0'\n implementation 'com.bugsee:bugsee-android-ndk:7.3.0'\n }\n}\n", + "dependencies {\n if (cond) {\n if (inner) {\n implementation 'com.bugsee:bugsee-android:7.3.0'\n implementation 'com.bugsee:bugsee-android-ndk:7.3.0'\n }\n }\n}\n", + }; + + [TestCaseSource(nameof(ConditionalDeclarations))] + public void Declarations_inside_control_flow_block_injection_but_not_an_existing_injection(string gradle) + { + foreach (bool kotlin in new[] { false, true }) + { + var plain = kotlin ? ApplyKts(gradle) : ApplyGroovy(gradle); + Assert.That(plain.Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.DeclaredByProject)); + Assert.That(plain.Text, Is.EqualTo(gradle)); + Assert.That(plain.Warnings, Has.Count.EqualTo(2), "one differing-version warning per artifact"); + + // An earlier injection stays: a conditional declaration is never a reason to delete it. + string injected = "dependencies {\n // bugsee:maven-dependencies begin (com.bugsee.unity)\n" + + KtsCore + "\n" + KtsNdk + "\n // bugsee:maven-dependencies end\n" + gradle.Substring("dependencies {\n".Length); + var again = kotlin ? ApplyKts(injected) : ApplyGroovy(injected); + Assert.That(again.Text, Is.EqualTo(injected)); + } + } + + [TestCase("dependencies {\n if (cond) {\n compileOnly 'com.bugsee:bugsee-android:7.4.1'\n testImplementation 'com.bugsee:bugsee-android:7.4.1'\n }\n}\n")] + [TestCase("dependencies {\n if (cond) {\n constraints {\n implementation 'com.bugsee:bugsee-android:7.4.1'\n }\n implementation platform('com.bugsee:bugsee-android:7.4.1')\n }\n}\n")] + [TestCase("dependencies {\n if (cond) {\n implementation('com.example:x:1') { exclude group: 'com.bugsee', module: 'bugsee-android' }\n }\n}\n")] + [TestCase("dependencies {\n configurations.all {\n implementation 'com.bugsee:bugsee-android:7.4.1'\n }\n}\n")] + public void Control_flow_blocks_with_only_non_declarations_are_still_injected(string gradle) + { + Assert.That(ApplyGroovy(gradle).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + Assert.That(ApplyKts(gradle).Status, Is.EqualTo(GradleMavenInjection.DependencyStatus.Injected)); + } + + [Test] + public void Exclusive_content_module_filter_that_misses_the_ndk_inserts_for_central_by_url() + { + string repository = + "exclusiveContent {\n" + + " forRepository { maven { url 'https://repo.maven.apache.org/maven2' } }\n" + + " filter { includeModule(\"com.bugsee\", \"bugsee-android\") }\n" + + " }"; + + Assert.That( + GradleMavenInjection.EnsureMavenCentral(Repos(repository), "allprojects", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + } + + [Test] + public void Wrapped_declarations_are_reported_with_their_whole_text_and_one_warning_per_artifact() + { + const string gradle = + "dependencies {\n" + + " implementation(\n" + + " \"com.bugsee:bugsee-android:7.3.0\"\n" + + " )\n" + + " implementation(\n" + + " \"com.bugsee:bugsee-android-ndk:7.3.0\"\n" + + " )\n" + + "}\n"; + var result = ApplyKts(gradle); + + Assert.That(result.Warnings, Has.Count.EqualTo(2)); + Assert.That(result.Warnings[0], Does.Contain("implementation( \"com.bugsee:bugsee-android:7.3.0\" )")); + Assert.That(result.Warnings[1], Does.Contain("bugsee-android-ndk:7.3.0")); + } + + [TestCase("repositories { mavenCentral() }")] + [TestCase("repositories { google(); mavenCentral() }")] + [TestCase("repositories { mavenCentral(); google() } // c")] + [TestCase("repositories { mavenCentral {} }")] + public void Single_line_repositories_with_central_are_present_and_untouched(string repositories) + { + string gradle = "allprojects {\n " + repositories + "\n}\n"; + + Assert.That( + GradleMavenInjection.EnsureMavenCentral(gradle, "allprojects", out string updated), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(updated, Is.EqualTo(gradle)); + } + + [TestCase("repositories { google() }", "repositories { mavenCentral(); google() }")] + [TestCase("repositories {}", "repositories { mavenCentral();}")] + [TestCase("repositories { google(); jcenter() }", "repositories { mavenCentral(); google(); jcenter() }")] + public void Single_line_repositories_without_central_get_it_inside_the_braces(string repositories, string expected) + { + string gradle = "allprojects {\n " + repositories + "\n}\n"; + + var status = GradleMavenInjection.EnsureMavenCentral(gradle, "allprojects", out string updated); + + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(updated, Is.EqualTo("allprojects {\n " + expected + "\n}\n")); + Assert.That( + GradleMavenInjection.EnsureMavenCentral(updated, "allprojects", out string twice), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(twice, Is.EqualTo(updated)); + } + + [Test] + public void Central_on_the_opening_line_of_a_multi_line_block_counts() + { + const string gradle = + "dependencyResolutionManagement {\n" + + " repositories { mavenCentral()\n" + + " google()\n" + + " }\n" + + "}\n"; + + Assert.That( + GradleMavenInjection.EnsureMavenCentral(gradle, "dependencyResolutionManagement", out _), + Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + } + } + + /// + /// Runs the injector over the real Gradle templates shipped with Unity 2021.3.45f1 (skipped when that editor + /// is not installed): results must be stable, balanced, and keep the marked lines in the intended blocks. + /// + public class UnityShippedTemplateTests + { + const string TemplateDir = + "/Applications/Unity/Hub/Editor/2021.3.45f1/PlaybackEngines/AndroidPlayer/Tools/GradleTemplates"; + + static readonly string[] Artifacts = { "bugsee-android", "bugsee-android-ndk" }; + + static string Read(string name) + { + string path = System.IO.Path.Combine(TemplateDir, name); + if (!System.IO.File.Exists(path)) + { + Assert.Ignore("Unity 2021.3.45f1 templates not installed: " + path); + } + + return System.IO.File.ReadAllText(path); + } + + static void AssertBalanced(string text) + { + int depth = 0; + foreach (char c in text) + { + if (c == '{') depth++; + if (c == '}') depth--; + Assert.That(depth, Is.GreaterThanOrEqualTo(0)); + } + + Assert.That(depth, Is.EqualTo(0)); + } + + /// The head line of the innermost block open at . + static string EnclosingBlock(string text, string marker) + { + var heads = new System.Collections.Generic.List(); + foreach (string line in text.Split('\n')) + { + if (line.Contains(marker)) + { + return heads.Count == 0 ? string.Empty : heads[heads.Count - 1]; + } + + foreach (char c in line) + { + if (c == '{') + { + heads.Add(line.Trim()); + } + else if (c == '}' && heads.Count > 0) + { + heads.RemoveAt(heads.Count - 1); + } + } + } + + return null; + } + + [TestCase("mainTemplate.gradle")] + [TestCase("launcherTemplate.gradle")] + [TestCase("libTemplate.gradle")] + [TestCase("baseProjectTemplate.gradle")] + [TestCase("settingsTemplate.gradle")] + [TestCase("gradleTemplate.properties")] + public void Dependency_injection_is_stable_balanced_and_inside_the_dependencies_block(string name) + { + string template = Read(name); + if (name.EndsWith(".properties")) + { + return; + } + + foreach (var dsl in new[] { GradleMavenInjection.GradleDsl.Groovy, GradleMavenInjection.GradleDsl.Kotlin }) + { + var first = GradleMavenInjection.ApplyDependencies(template, "com.bugsee", Artifacts, "7.4.1", dsl); + var second = GradleMavenInjection.ApplyDependencies(first.Text, "com.bugsee", Artifacts, "7.4.1", dsl); + + Assert.That(second.Text, Is.EqualTo(first.Text)); + AssertBalanced(first.Text); + Assert.That(first.Text, Does.Contain("bugsee:maven-dependencies begin")); + Assert.That(EnclosingBlock(first.Text, "bugsee:maven-dependencies begin"), Does.StartWith("dependencies")); + } + } + + [TestCase("settingsTemplate.gradle", "dependencyResolutionManagement")] + [TestCase("baseProjectTemplate.gradle", "allprojects")] + [TestCase("mainTemplate.gradle", "allprojects")] + [TestCase("launcherTemplate.gradle", "allprojects")] + public void Repository_edit_is_stable_balanced_and_never_a_stray_statement(string name, string outer) + { + string template = Read(name); + var status = GradleMavenInjection.EnsureMavenCentral(template, outer, out string updated); + var again = GradleMavenInjection.EnsureMavenCentral(updated, outer, out string twice); + + AssertBalanced(updated); + Assert.That(twice, Is.EqualTo(updated)); + if (status == GradleMavenInjection.RepositoryStatus.BlockNotFound) + { + Assert.That(updated, Is.EqualTo(template)); + } + else + { + Assert.That(again, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(updated.Contains("mavenCentral()")); + } + + if (status == GradleMavenInjection.RepositoryStatus.Inserted) + { + Assert.That(EnclosingBlock(updated, "bugsee:maven-repository"), Does.StartWith("repositories")); + } + } + + [Test] + public void Settings_template_without_central_gets_it_in_the_project_repositories() + { + string settings = Read("settingsTemplate.gradle"); + int project = settings.IndexOf("dependencyResolutionManagement", System.StringComparison.Ordinal); + string stripped = settings.Substring(0, project) + + settings.Substring(project).Replace(" mavenCentral()\n", string.Empty); + + var result = GradleMavenInjection.EnsureProjectRepositories(stripped, null); + + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + AssertBalanced(result.Settings); + Assert.That(EnclosingBlock(result.Settings, "bugsee:maven-repository"), Does.StartWith("repositories")); + Assert.That(result.Settings.IndexOf("bugsee:maven-repository"), Is.GreaterThan(project)); + } } } From a3bc9b2978599edba21a9146fb7a9960562b7118 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 00:21:43 +0500 Subject: [PATCH 112/157] Expose GetLaunchOptions and Notify on Android and iOS. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetLaunchOptions returns the effective options as a read-only dictionary (null until launched); Notify relays a lightweight notification without a report. APM transactions/spans are left out; see PR description. Fixes #72 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 + Documentation~/index.md | 10 ++ Plugins/iOS/BugseeUnityBridge.mm | 69 ++++++++ Runtime/Bugsee.cs | 37 ++++ Runtime/Platform/Android/AndroidBridge.cs | 20 +++ Runtime/Platform/Editor/EditorBridge.cs | 2 + Runtime/Platform/IBugseeNativeBridge.cs | 6 + Runtime/Platform/IOS/IOSBridge.cs | 9 + Runtime/WrapperPolicy/JsonRead.cs | 163 ++++++++++++++++++ Runtime/WrapperPolicy/JsonRead.cs.meta | 11 ++ Runtime/WrapperPolicy/LaunchOptionsWire.cs | 55 ++++++ .../WrapperPolicy/LaunchOptionsWire.cs.meta | 11 ++ Runtime/WrapperPolicy/NotifyPolicy.cs | 46 +++++ Runtime/WrapperPolicy/NotifyPolicy.cs.meta | 11 ++ Tests~/WrapperPolicy/JsonReadTests.cs | 82 +++++++++ .../WrapperPolicy/LaunchOptionsWireTests.cs | 59 +++++++ Tests~/WrapperPolicy/NotifyPolicyTests.cs | 48 ++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 29 ++++ Tests~/iOS/BridgeTests.mm | 16 ++ 19 files changed, 686 insertions(+) create mode 100644 Runtime/WrapperPolicy/JsonRead.cs create mode 100644 Runtime/WrapperPolicy/JsonRead.cs.meta create mode 100644 Runtime/WrapperPolicy/LaunchOptionsWire.cs create mode 100644 Runtime/WrapperPolicy/LaunchOptionsWire.cs.meta create mode 100644 Runtime/WrapperPolicy/NotifyPolicy.cs create mode 100644 Runtime/WrapperPolicy/NotifyPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/JsonReadTests.cs create mode 100644 Tests~/WrapperPolicy/LaunchOptionsWireTests.cs create mode 100644 Tests~/WrapperPolicy/NotifyPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf523d..68332de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `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. - Initial UPM package scaffold (`com.bugsee.unity`). - Android native SDK from Maven Central (`com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1`), declared in the generated Gradle project at build time. Installing is just adding the package: no EDM4U, no OpenUPM scoped registry, no resolve step, no custom Gradle templates. A Bugsee dependency the project already declares (EDM4U, custom template, local AAR) is kept, and a different version is reported. Core AAR publishes no transitives; NDK is declared explicitly. - Android Gradle plugin `4.0.8` (NDK enabled on Unity 6+ launcher templates). diff --git a/Documentation~/index.md b/Documentation~/index.md index 44851d7..4530022 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -218,4 +218,14 @@ Wired: Launch/Stop, blackout, log/trace/event, exceptions (JSON-in-reason), Test `SetAttribute` accepts a string (up to 1024 UTF-16 units), a bool or a number within the signed 64-bit range; anything else throws `ArgumentException`. `GetAttribute` returns the kind that was set: `string`, `bool`, an integer or a `double`. The numeric width is not promised (Android returns the boxed type that was set, with `sbyte`/`ushort` stored as `int`, `uint`/`ulong` as `long` and `decimal` as `double`; iOS returns `long` or `double`), and a 1.0 set as floating point stays floating. A container set natively reads as a `Dictionary` on Android and as JSON text on iOS. On iOS the bridge returns the value as a type tag and payload (`s:`, `b:`, `i:`, `d:`, `j:`; see `AttributeWire`); the SDK stores the plain value, so reports show it unwrapped. +### Launch options and notifications + +`Bugsee.GetLaunchOptions()` returns the options the SDK is running with (its defaults with the ones given to `Launch` merged over them) as a read-only snapshot, or `null` while the SDK is not launched. Keys are each platform's own option names, so they are not the same on Android and iOS. Values are `string`, `bool`, `long` or `double` on both platforms (the SDK's narrower `int`/`float` defaults are widened), or a nested `Dictionary` / `List`; a value with no such form (a date, a Java array) reads as its text. The SDK's internal keys (those starting with two dollar signs) are not included. + +`Bugsee.Notify(title, body = null, severity = IssueSeverity.Medium, fields = null, urgent = false)` relays a lightweight notification to the app's messaging integrations without creating a report (no video, logs or events). It is stored offline and sent in batches; `urgent` asks for immediate delivery. A call with an empty title, or before the SDK is launched, is ignored by the native SDK (the title case also logs a Unity warning). `fields` are extra string pairs; entries with a null key or value are dropped. The severity is always sent, so the native default for the shorter overloads is not used. + +### APM (not exposed yet) + +Both native SDKs have `startTransaction` / `startSpan` / `getActiveSpan`, but the Unity package does not expose them yet: a span is a live native object (it needs a handle that crosses JNI and P/Invoke and a lifetime rule), the active span is per native thread (not propagated across `dispatch_async` on iOS), and the no-op behaviour when APM is disabled is only documented on iOS. See the proposal on the tracking issue. + Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 409e832..d427eeb 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -430,6 +430,73 @@ void _bugsee_upload(const char *summary, const char *description, int severity, labels:labels]; } +// NSJSONSerialization throws on anything outside the JSON types (NSDate, NSData, blocks, non-string keys, +// NaN), and launch options can hold such values, so a value it cannot take crosses as its description. +static id BugseeJsonSafe(id value, int depth) +{ + if (!value || value == [NSNull null] || depth > 16) { + return [NSNull null]; + } + if ([value isKindOfClass:[NSString class]]) { + return value; + } + if ([value isKindOfClass:[NSNumber class]]) { + double d = [value doubleValue]; + return (isnan(d) || isinf(d)) ? [NSNull null] : value; + } + if ([value isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *out = [NSMutableDictionary dictionaryWithCapacity:[value count]]; + [value enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + NSString *name = [key isKindOfClass:[NSString class]] ? key : [key description]; + out[name] = BugseeJsonSafe(obj, depth + 1); + }]; + return out; + } + if ([value isKindOfClass:[NSArray class]] || [value isKindOfClass:[NSSet class]]) { + NSMutableArray *out = [NSMutableArray arrayWithCapacity:[value count]]; + for (id item in value) { + [out addObject:BugseeJsonSafe(item, depth + 1)]; + } + return out; + } + return [value description]; +} + +char *_bugsee_get_launch_options(void) +{ + NSDictionary *options = [Bugsee getLaunchOptions]; + id safe = BugseeJsonSafe(options, 0); + if (![safe isKindOfClass:[NSDictionary class]] || ![NSJSONSerialization isValidJSONObject:safe]) { + return NULL; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:safe options:0 error:nil]; + NSString *json = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil; + return BugseeCopyUTF8(json); +} + +void _bugsee_notify(const char *title, const char *body, int severity, const char *fieldsJson, int urgent) +{ + NSString *titleStr = BugseeNSString(title); + if (titleStr.length == 0) { + return; + } + NSMutableDictionary *fields = nil; + id parsed = BugseeDeserializeJson(fieldsJson); + if ([parsed isKindOfClass:[NSDictionary class]]) { + fields = [NSMutableDictionary dictionary]; + [parsed enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if ([key isKindOfClass:[NSString class]] && [obj isKindOfClass:[NSString class]]) { + fields[key] = obj; + } + }]; + } + [Bugsee notifyWithTitle:titleStr + body:BugseeNSString(body) + severity:(BugseeSeverityLevel)severity + fields:fields + urgent:urgent != 0]; +} + void _bugsee_set_attribute(const char *key, const char *valueJson) { id value = valueJson ? BugseeDeserializeJson(valueJson) : nil; @@ -820,6 +887,8 @@ void _bugsee_end_blackout(void) {} void _bugsee_test_crash(void) {} void _bugsee_show_report(const char *summary, const char *description, int severity, const char *labelsJson) { (void)summary; (void)description; (void)severity; (void)labelsJson; } void _bugsee_upload(const char *summary, const char *description, int severity, const char *labelsJson) { (void)summary; (void)description; (void)severity; (void)labelsJson; } +char *_bugsee_get_launch_options(void) { return NULL; } +void _bugsee_notify(const char *title, const char *body, int severity, const char *fieldsJson, int urgent) { (void)title; (void)body; (void)severity; (void)fieldsJson; (void)urgent; } void _bugsee_set_attribute(const char *key, const char *valueJson) { (void)key; (void)valueJson; } char *_bugsee_get_attribute(const char *key) { (void)key; return NULL; } void _bugsee_clear_attribute(const char *key) { (void)key; } diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 82816f8..0665a2c 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Threading.Tasks; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; @@ -165,6 +166,20 @@ static bool IsStartingOrLaunched(BugseeStatus status) => public static BugseeStatus Status => Bridge.GetStatus(); + /// + /// The options the SDK is running with: its defaults with the ones passed to merged over + /// them, as a read-only snapshot (changing it changes nothing). Null while the SDK is not launched. Keys are + /// the native SDK's own option names and differ between Android and iOS; values are string, bool, long, + /// double (or a nested dictionary or list); narrower numeric types are widened the same way on both platforms. The + /// SDK's internal keys (starting with a double dollar sign) are left out. A value with no such form, such as a + /// Java array, reads as its text. + /// + public static IReadOnlyDictionary GetLaunchOptions() + { + var options = LaunchOptionsWire.Normalize(Bridge.GetLaunched() ? Bridge.GetLaunchOptions() : null); + return options == null ? null : new ReadOnlyDictionary(options); + } + public static void StartBlackout() => Bridge.StartBlackout(); public static void EndBlackout() => Bridge.EndBlackout(); @@ -239,6 +254,28 @@ public static void Upload( /// Upload a report created with . public static void Upload(IReport report) => Bridge.UploadReport(report); + /// + /// Relay a lightweight notification to the app's messaging integrations, without creating a report (no video, + /// logs or events are attached). It is stored offline and uploaded in batches. Ignored, with a native warning, + /// when is empty or the SDK is not launched. asks for + /// delivery without waiting for the batch; are extra string key/value pairs + /// (entries with a null key or value are dropped). + /// + public static void Notify( + string title, + string body = null, + IssueSeverity severity = IssueSeverity.Medium, + IDictionary fields = null, + bool urgent = false) + { + if (!NotifyPolicy.HasTitle(title)) + { + Debug.LogWarning("[Bugsee] Notify ignored: a title is required."); + return; + } + Bridge.Notify(title, body, severity, NotifyPolicy.CleanFields(fields), urgent); + } + public static void SetUserIdentifier(string userIdentifier) => Bridge.SetUserIdentifier(userIdentifier); diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index e6c5467..b135f3a 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -117,6 +117,16 @@ void Stop(Action completion, Action finished) public bool GetLaunched() => _bugsee.CallStatic("getLaunched"); + public IReadOnlyDictionary GetLaunchOptions() + { + using (var options = _bugsee.CallStatic("getLaunchOptions")) + { + if (options == null) return null; + using (var map = options.Call("toMap")) + return map == null ? null : AndroidJavaConverters.MapToDictionary(map); + } + } + public BugseeStatus GetStatus() { using (var status = _bugsee.CallStatic("getStatus")) @@ -335,6 +345,16 @@ public void Upload(string summary, string description, IssueSeverity severity, I } } + public void Notify(string title, string body, IssueSeverity severity, IDictionary fields, bool urgent) + { + using (var sev = Severity(severity)) + using (var javaFields = AndroidJavaConverters.StringDictionaryToMap(fields)) + { + // A null cannot select a JNI overload, so no fields is an empty map (and no body an empty string). + _bugsee.CallStatic("notify", title, NotifyPolicy.Body(body), sev, javaFields, urgent); + } + } + public void SetUserIdentifier(string userIdentifier) { var normalized = UserIdentifierPolicy.ForSet(userIdentifier); diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index d7239b6..b2d5e38 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -25,6 +25,7 @@ public void Relaunch(IDictionary options) { } public void Stop(Action completion = null) => completion?.Invoke(); public bool GetLaunched() => false; public BugseeStatus GetStatus() => BugseeStatus.Stopped; + public IReadOnlyDictionary GetLaunchOptions() => null; bool _blackout; public void StartBlackout() => _blackout = true; @@ -45,6 +46,7 @@ public void LogUnhandledExceptionPayload(ManagedExceptionPayload.Payload payload public void TestCrash() { } public void ShowReportDialog(string summary, string description, IssueSeverity severity, IList labels) { } public void Upload(string summary, string description, IssueSeverity severity, IList labels) { } + public void Notify(string title, string body, IssueSeverity severity, IDictionary fields, bool urgent) { } public void SetUserIdentifier(string userIdentifier) { } public string GetUserIdentifier() => null; public void ClearUserIdentifier() { } diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index 81b16fc..6dda9d9 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -21,6 +21,9 @@ public interface IBugseeNativeBridge bool GetLaunched(); BugseeStatus GetStatus(); + /// The effective launch options (SDK defaults merged with the host's), or null when the SDK has none. + IReadOnlyDictionary GetLaunchOptions(); + void StartBlackout(); void EndBlackout(); bool IsBlackout(); @@ -45,6 +48,9 @@ public interface IBugseeNativeBridge void ShowReportDialog(string summary, string description, IssueSeverity severity, IList labels); void Upload(string summary, string description, IssueSeverity severity, IList labels); + /// Relay a lightweight notification to the app's messaging integrations (no report is created). + void Notify(string title, string body, IssueSeverity severity, IDictionary fields, bool urgent); + void SetUserIdentifier(string userIdentifier); string GetUserIdentifier(); void ClearUserIdentifier(); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 452c5d7..d0ca501 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -395,6 +395,9 @@ static void OnNativeStarted(int token, int success) public bool GetLaunched() => GetStatus() == BugseeStatus.Launched; + public IReadOnlyDictionary GetLaunchOptions() => + JsonRead.Object(ConsumeNativeString(_bugsee_get_launch_options())); + // The SDK owns the state, including Launching and Stopping, so a launch it refused reads as Stopped // and so does anything before the first Launch (a nil instance answers 0). public BugseeStatus GetStatus() @@ -658,6 +661,10 @@ public void ShowReportDialog(string summary, string description, IssueSeverity s public void Upload(string summary, string description, IssueSeverity severity, IList labels) => _bugsee_upload(summary ?? "", description ?? "", (int)severity, ToJsonStringArray(labels)); + public void Notify(string title, string body, IssueSeverity severity, IDictionary fields, bool urgent) => + _bugsee_notify(title ?? "", NotifyPolicy.Body(body), (int)severity, + ToJsonObject(NotifyPolicy.ToWireMap(fields)), urgent ? 1 : 0); + public void SetUserIdentifier(string userIdentifier) { var normalized = UserIdentifierPolicy.ForSet(userIdentifier); @@ -862,6 +869,8 @@ static IDictionary WithWrapperInfo(IDictionary o [DllImport("__Internal")] static extern void _bugsee_test_crash(); [DllImport("__Internal")] static extern void _bugsee_show_report(string summary, string description, int severity, string labelsJson); [DllImport("__Internal")] static extern void _bugsee_upload(string summary, string description, int severity, string labelsJson); + [DllImport("__Internal")] static extern void _bugsee_notify(string title, string body, int severity, string fieldsJson, int urgent); + [DllImport("__Internal")] static extern IntPtr _bugsee_get_launch_options(); [DllImport("__Internal")] static extern void _bugsee_set_attribute(string key, string valueJson); [DllImport("__Internal")] static extern IntPtr _bugsee_get_attribute(string key); [DllImport("__Internal")] static extern void _bugsee_clear_attribute(string key); diff --git a/Runtime/WrapperPolicy/JsonRead.cs b/Runtime/WrapperPolicy/JsonRead.cs new file mode 100644 index 0000000..a707e2b --- /dev/null +++ b/Runtime/WrapperPolicy/JsonRead.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Reads the JSON object the iOS bridge returns (launch options) into plain managed values: object to + /// Dictionary<string, object>, array to List<object>, string, bool, long (or double when + /// the number is fractional, has an exponent or does not fit a long) and null. Malformed input yields null, never + /// an exception, and nesting is bounded by . + /// + public static class JsonRead + { + public static Dictionary Object(string json) + { + if (string.IsNullOrEmpty(json)) return null; + try + { + int i = 0; + SkipSpace(json, ref i); + if (i >= json.Length || json[i] != '{') return null; + var map = (Dictionary)ReadValue(json, ref i, 0); + SkipSpace(json, ref i); + return i == json.Length ? map : null; + } + catch (FormatException) + { + return null; + } + } + + static void SkipSpace(string s, ref int i) + { + while (i < s.Length && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) i++; + } + + static object ReadValue(string s, ref int i, int depth) + { + if (depth > JsonWire.MaxDepth) throw new FormatException(); + SkipSpace(s, ref i); + if (i >= s.Length) throw new FormatException(); + char c = s[i]; + if (c == '{') return ReadObject(s, ref i, depth); + if (c == '[') return ReadArray(s, ref i, depth); + if (c == '"') return ReadString(s, ref i); + if (Match(s, ref i, "true")) return true; + if (Match(s, ref i, "false")) return false; + if (Match(s, ref i, "null")) return null; + return ReadNumber(s, ref i); + } + + static bool Match(string s, ref int i, string word) + { + if (string.CompareOrdinal(s, i, word, 0, word.Length) != 0) return false; + i += word.Length; + return true; + } + + static Dictionary ReadObject(string s, ref int i, int depth) + { + var map = new Dictionary(); + i++; + SkipSpace(s, ref i); + if (i < s.Length && s[i] == '}') { i++; return map; } + while (true) + { + SkipSpace(s, ref i); + if (i >= s.Length || s[i] != '"') throw new FormatException(); + string key = ReadString(s, ref i); + SkipSpace(s, ref i); + if (i >= s.Length || s[i] != ':') throw new FormatException(); + i++; + map[key] = ReadValue(s, ref i, depth + 1); + SkipSpace(s, ref i); + if (i >= s.Length) throw new FormatException(); + if (s[i] == ',') { i++; continue; } + if (s[i] == '}') { i++; return map; } + throw new FormatException(); + } + } + + static List ReadArray(string s, ref int i, int depth) + { + var list = new List(); + i++; + SkipSpace(s, ref i); + if (i < s.Length && s[i] == ']') { i++; return list; } + while (true) + { + list.Add(ReadValue(s, ref i, depth + 1)); + SkipSpace(s, ref i); + if (i >= s.Length) throw new FormatException(); + if (s[i] == ',') { i++; continue; } + if (s[i] == ']') { i++; return list; } + throw new FormatException(); + } + } + + static string ReadString(string s, ref int i) + { + var sb = new StringBuilder(); + i++; + while (i < s.Length) + { + char c = s[i++]; + if (c == '"') return sb.ToString(); + if (c != '\\') { sb.Append(c); continue; } + if (i >= s.Length) break; + char e = s[i++]; + switch (e) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + if (i + 4 > s.Length || + !ushort.TryParse(s.Substring(i, 4), NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, out ushort unit)) + throw new FormatException(); + sb.Append((char)unit); + i += 4; + break; + default: throw new FormatException(); + } + } + throw new FormatException(); + } + + static void Digits(string s, ref int i) + { + int begin = i; + while (i < s.Length && s[i] >= '0' && s[i] <= '9') i++; + if (i == begin) throw new FormatException(); + } + + static object ReadNumber(string s, ref int i) + { + // The JSON grammar: -? (0 | [1-9][0-9]*) (. [0-9]+)? ([eE] [+-]? [0-9]+)? + int start = i; + if (i < s.Length && s[i] == '-') i++; + if (i < s.Length && s[i] == '0') i++; + else Digits(s, ref i); + if (i < s.Length && s[i] == '.') { i++; Digits(s, ref i); } + if (i < s.Length && (s[i] == 'e' || s[i] == 'E')) + { + i++; + if (i < s.Length && (s[i] == '+' || s[i] == '-')) i++; + Digits(s, ref i); + } + string text = s.Substring(start, i - start); + if (long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long l)) return l; + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return d; + throw new FormatException(); + } + } +} diff --git a/Runtime/WrapperPolicy/JsonRead.cs.meta b/Runtime/WrapperPolicy/JsonRead.cs.meta new file mode 100644 index 0000000..0c78c63 --- /dev/null +++ b/Runtime/WrapperPolicy/JsonRead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b1c0e4d93a24f5e8a6d2c51f0b93e17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/LaunchOptionsWire.cs b/Runtime/WrapperPolicy/LaunchOptionsWire.cs new file mode 100644 index 0000000..c2b0654 --- /dev/null +++ b/Runtime/WrapperPolicy/LaunchOptionsWire.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Makes the launch options read the same on both platforms: integer types widen to long and float to double + /// (Android returns Integer/Float for some defaults, iOS parses JSON as long/double), also inside nested maps and + /// lists, and the SDK-internal keys starting with $$ (Android's $$ENDPOINT, $$DEBUG, + /// $$WRAPPER) are left out. + /// + public static class LaunchOptionsWire + { + public const string InternalKeyPrefix = "$$"; + + public static Dictionary Normalize(IEnumerable> options) + { + if (options == null) return null; + var result = new Dictionary(); + foreach (var kv in options) + { + if (kv.Key == null || kv.Key.StartsWith(InternalKeyPrefix, StringComparison.Ordinal)) continue; + result[kv.Key] = Value(kv.Value); + } + return result; + } + + static object Value(object value) + { + switch (value) + { + case sbyte v: return (long)v; + case byte v: return (long)v; + case short v: return (long)v; + case ushort v: return (long)v; + case int v: return (long)v; + case uint v: return (long)v; + case float v: return (double)v; + case ulong v: return v <= long.MaxValue ? (object)(long)v : (double)v; + case string s: return s; + case IDictionary map: + var copy = new Dictionary(map.Count); + foreach (var kv in map) + if (kv.Key != null) copy[kv.Key] = Value(kv.Value); + return copy; + case IEnumerable seq: + var list = new List(); + foreach (var item in seq) list.Add(Value(item)); + return list; + default: return value; + } + } + } +} diff --git a/Runtime/WrapperPolicy/LaunchOptionsWire.cs.meta b/Runtime/WrapperPolicy/LaunchOptionsWire.cs.meta new file mode 100644 index 0000000..f5e2736 --- /dev/null +++ b/Runtime/WrapperPolicy/LaunchOptionsWire.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e0f3b9a7c1d4a28b6e4d9f20a8c7b13 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/NotifyPolicy.cs b/Runtime/WrapperPolicy/NotifyPolicy.cs new file mode 100644 index 0000000..44b7ff8 --- /dev/null +++ b/Runtime/WrapperPolicy/NotifyPolicy.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Argument rules for Bugsee.Notify, shared by both bridges. Both native SDKs ignore a notification without + /// a title (logging a warning) and take the fields as a string-to-string map. + /// + public static class NotifyPolicy + { + /// + /// The body both bridges send: null becomes empty. A JNI call cannot pass a null to pick the Android overload, + /// so iOS sends the same empty string and the two platforms agree. + /// + public static string Body(string body) => body ?? ""; + + public static bool HasTitle(string title) => !string.IsNullOrEmpty(title); + + /// + /// A copy of without null keys or null values (a native string map cannot hold + /// them), or null when nothing is left. + /// + public static Dictionary CleanFields(IDictionary fields) + { + if (fields == null) return null; + Dictionary clean = null; + foreach (var kv in fields) + { + if (kv.Key == null || kv.Value == null) continue; + if (clean == null) clean = new Dictionary(); + clean[kv.Key] = kv.Value; + } + return clean; + } + + /// The cleaned fields as the object map writes, or null when none are left. + public static IDictionary ToWireMap(IDictionary fields) + { + var clean = CleanFields(fields); + if (clean == null) return null; + var map = new Dictionary(clean.Count); + foreach (var kv in clean) map[kv.Key] = kv.Value; + return map; + } + } +} diff --git a/Runtime/WrapperPolicy/NotifyPolicy.cs.meta b/Runtime/WrapperPolicy/NotifyPolicy.cs.meta new file mode 100644 index 0000000..8deaafe --- /dev/null +++ b/Runtime/WrapperPolicy/NotifyPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d84a6f1c5b34e07b9d13a8e6c40f2a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/JsonReadTests.cs b/Tests~/WrapperPolicy/JsonReadTests.cs new file mode 100644 index 0000000..b3e7766 --- /dev/null +++ b/Tests~/WrapperPolicy/JsonReadTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class JsonReadTests + { + [Test] + public void Reads_scalars_with_their_kind() + { + var map = JsonRead.Object("{\"s\":\"a\\n\\u00e9\\\"\",\"t\":true,\"f\":false,\"n\":null,\"i\":-12,\"d\":1.5,\"e\":1e3,\"big\":18446744073709551615}"); + Assert.That(map["s"], Is.EqualTo("a\n\u00e9\"")); + Assert.That(map["t"], Is.EqualTo(true)); + Assert.That(map["f"], Is.EqualTo(false)); + Assert.That(map["n"], Is.Null); + Assert.That(map["i"], Is.EqualTo(-12L)); + Assert.That(map["d"], Is.EqualTo(1.5)); + Assert.That(map["e"], Is.EqualTo(1000.0)); + Assert.That(map["big"], Is.EqualTo(18446744073709551615.0)); + } + + [Test] + public void Reads_nested_containers() + { + var map = JsonRead.Object(" { \"w\" : { \"type\":\"UNITY\", \"xs\":[1, \"b\", [true]] }, \"empty\":{}, \"none\":[] } "); + var w = (Dictionary)map["w"]; + Assert.That(w["type"], Is.EqualTo("UNITY")); + var xs = (List)w["xs"]; + Assert.That(xs[0], Is.EqualTo(1L)); + Assert.That(xs[1], Is.EqualTo("b")); + Assert.That(((List)xs[2])[0], Is.EqualTo(true)); + Assert.That(((Dictionary)map["empty"]).Count, Is.EqualTo(0)); + Assert.That(((List)map["none"]).Count, Is.EqualTo(0)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("[]")] + [TestCase("1")] + [TestCase("{")] + [TestCase("{\"a\":}")] + [TestCase("{\"a\":1,}")] + [TestCase("{\"a\":1} x")] + [TestCase("{\"a\":\"unterminated}")] + [TestCase("{\"a\":\"\\q\"}")] + [TestCase("{\"a\":\"\\u12\"}")] + [TestCase("{\"a\":-}")] + [TestCase("{\"a\":01}")] + [TestCase("{\"a\":1.}")] + [TestCase("{\"a\":.5}")] + [TestCase("{\"a\":1e}")] + [TestCase("{\"a\":+1}")] + [TestCase("{\"a\":--1}")] + public void Malformed_input_is_null_not_an_exception(string json) + { + Assert.That(JsonRead.Object(json), Is.Null); + } + + [Test] + public void Deep_nesting_is_refused_instead_of_overflowing() + { + string deep = new string('[', 500) + new string(']', 500); + Assert.That(JsonRead.Object("{\"a\":" + deep + "}"), Is.Null); + } + + [Test] + public void Round_trips_what_JsonWire_writes() + { + var source = new Dictionary + { + { "text", "line\n\"q\" \u2603" }, { "n", 3 }, { "x", 0.25 }, { "on", true }, + { "inner", new Dictionary { { "k", "v" } } }, + }; + var back = JsonRead.Object(JsonWire.Object(source)); + Assert.That(back["text"], Is.EqualTo("line\n\"q\" \u2603")); + Assert.That(back["n"], Is.EqualTo(3L)); + Assert.That(back["x"], Is.EqualTo(0.25)); + Assert.That(back["on"], Is.EqualTo(true)); + Assert.That(((Dictionary)back["inner"])["k"], Is.EqualTo("v")); + } + } +} diff --git a/Tests~/WrapperPolicy/LaunchOptionsWireTests.cs b/Tests~/WrapperPolicy/LaunchOptionsWireTests.cs new file mode 100644 index 0000000..3873c81 --- /dev/null +++ b/Tests~/WrapperPolicy/LaunchOptionsWireTests.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class LaunchOptionsWireTests + { + [Test] + public void Narrow_numbers_widen_so_a_long_or_double_cast_works() + { + var map = LaunchOptionsWire.Normalize(new Dictionary + { + { "i", 5 }, { "s", (short)6 }, { "b", (byte)7 }, { "f", 0.5f }, { "l", 9L }, { "d", 1.25 }, + { "t", true }, { "str", "x" }, { "n", null }, { "u", ulong.MaxValue }, + }); + Assert.That((long)map["i"], Is.EqualTo(5L)); + Assert.That((long)map["s"], Is.EqualTo(6L)); + Assert.That((long)map["b"], Is.EqualTo(7L)); + Assert.That((double)map["f"], Is.EqualTo(0.5)); + Assert.That(map["l"], Is.EqualTo(9L)); + Assert.That(map["d"], Is.EqualTo(1.25)); + Assert.That(map["t"], Is.EqualTo(true)); + Assert.That(map["str"], Is.EqualTo("x")); + Assert.That(map["n"], Is.Null); + Assert.That(map["u"], Is.TypeOf()); + } + + [Test] + public void Nested_maps_and_lists_are_widened_too() + { + var map = LaunchOptionsWire.Normalize(new Dictionary + { + { "m", new Dictionary { { "k", 1 } } }, + { "l", new List { 2, 0.5f, "s" } }, + }); + Assert.That(((Dictionary)map["m"])["k"], Is.EqualTo(1L)); + var list = (List)map["l"]; + Assert.That(list[0], Is.EqualTo(2L)); + Assert.That(list[1], Is.EqualTo(0.5)); + Assert.That(list[2], Is.EqualTo("s")); + } + + [Test] + public void Internal_dollar_keys_are_left_out() + { + var map = LaunchOptionsWire.Normalize(new Dictionary + { + { "$$ENDPOINT", "x" }, { "$$DEBUG", true }, { "$$WRAPPER", "w" }, { "keep", 1 }, { "a$$b", 2 }, + }); + Assert.That(map.Keys, Is.EquivalentTo(new[] { "keep", "a$$b" })); + } + + [Test] + public void Null_options_stay_null() + { + Assert.That(LaunchOptionsWire.Normalize(null), Is.Null); + } + } +} diff --git a/Tests~/WrapperPolicy/NotifyPolicyTests.cs b/Tests~/WrapperPolicy/NotifyPolicyTests.cs new file mode 100644 index 0000000..52779e9 --- /dev/null +++ b/Tests~/WrapperPolicy/NotifyPolicyTests.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class NotifyPolicyTests + { + [TestCase(null, false)] + [TestCase("", false)] + [TestCase(" ", true)] + [TestCase("Deploy done", true)] + public void Title_is_required(string title, bool expected) + { + Assert.That(NotifyPolicy.HasTitle(title), Is.EqualTo(expected)); + } + + [Test] + public void Null_body_is_empty_on_both_platforms() + { + Assert.That(NotifyPolicy.Body(null), Is.EqualTo("")); + Assert.That(NotifyPolicy.Body("b"), Is.EqualTo("b")); + } + + [Test] + public void Null_or_empty_fields_are_no_fields() + { + Assert.That(NotifyPolicy.CleanFields(null), Is.Null); + Assert.That(NotifyPolicy.CleanFields(new Dictionary()), Is.Null); + Assert.That(NotifyPolicy.ToWireMap(null), Is.Null); + } + + [Test] + public void Null_values_are_dropped_and_the_rest_kept() + { + var clean = NotifyPolicy.CleanFields(new Dictionary { { "a", "1" }, { "b", null } }); + Assert.That(clean.Count, Is.EqualTo(1)); + Assert.That(clean["a"], Is.EqualTo("1")); + Assert.That(NotifyPolicy.CleanFields(new Dictionary { { "b", null } }), Is.Null); + } + + [Test] + public void Wire_map_is_the_json_the_ios_bridge_sends() + { + var map = NotifyPolicy.ToWireMap(new Dictionary { { "env", "prod" }, { "skip", null } }); + Assert.That(JsonWire.Object(map), Is.EqualTo("{\"env\":\"prod\"}")); + } + } +} diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 933fcc9..7bf6ab0 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -115,6 +115,35 @@ public void Ios_launch_passes_the_started_answer_back_by_token() Assert.That(relaunch, Does.Contain("started:BugseeStartedBlockFor(startedToken, started)")); } + [Test] + public void Facade_launch_options_are_null_until_launched_and_read_only() + { + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + string body = ExtractMethodBody(facade, "public static IReadOnlyDictionary GetLaunchOptions()"); + Assert.That(body, Does.Contain("LaunchOptionsWire.Normalize(Bridge.GetLaunched() ? Bridge.GetLaunchOptions() : null)")); + Assert.That(body, Does.Contain("new ReadOnlyDictionary")); + } + + [Test] + public void Facade_notify_requires_a_title_and_cleans_the_fields_before_the_bridge() + { + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + string body = ExtractMethodBody(facade, "public static void Notify("); + Assert.That(body, Does.Contain("NotifyPolicy.HasTitle(title)")); + Assert.That(body, Does.Contain("Bridge.Notify(title, body, severity, NotifyPolicy.CleanFields(fields), urgent)")); + } + + [Test] + public void Notify_reaches_the_native_five_argument_overloads() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(ExtractMethodBody(android, "public void Notify("), Does.Contain("_bugsee.CallStatic(\"notify\", title, NotifyPolicy.Body(body), sev, javaFields, urgent)")); + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + string native = ExtractNativeFunctionBody(bridge, "_bugsee_notify"); + Assert.That(native, Does.Contain("notifyWithTitle:titleStr")); + Assert.That(native, Does.Contain("urgent:urgent != 0")); + } + [Test] public void Facade_async_lifecycle_uses_the_result_bridge_and_never_blocks() { diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index d7dc863..bf1147c 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -16,6 +16,8 @@ void _bugsee_set_attribute(const char *key, const char *valueJson); char *_bugsee_get_attribute(const char *key); void _bugsee_clear_attribute(const char *key); +char *_bugsee_get_launch_options(void); +void _bugsee_notify(const char *title, const char *body, int severity, const char *fieldsJson, int urgent); int _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a); char *_bugsee_appearance_get_color(const char *propertyName); int _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue); @@ -255,6 +257,20 @@ int main(void) _bugsee_launch("00000000-0000-0000-0000-000000000000", "{\"max_recording_time\":60}", 41, OnStarted); expect(waitFor(^{ return gStartedCount == 1 ? 1 : 0; }, 5) && gStartedToken == 41, "launch reports the SDK's started answer with its token"); + // The effective options are valid JSON holding the host's value merged over the SDK defaults. + char *launchOptions = _bugsee_get_launch_options(); + id parsedOptions = launchOptions + ? [NSJSONSerialization JSONObjectWithData:[NSData dataWithBytes:launchOptions length:strlen(launchOptions)] options:0 error:nil] + : nil; + expect([parsedOptions isKindOfClass:[NSDictionary class]] && [parsedOptions count] > 1, + "launch options come back as a JSON object with the defaults"); + expect([parsedOptions[BugseeMaxRecordingTimeKey] intValue] == 60, "launch options hold the host's value"); + free(launchOptions); + // notify takes every argument shape without throwing; the title is required and fields are strings only. + _bugsee_notify("title", NULL, 2, NULL, 0); + _bugsee_notify("title", "body", 3, "{\"env\":\"prod\",\"n\":1}", 1); + _bugsee_notify("", "body", 3, NULL, 0); + _bugsee_notify(NULL, NULL, 3, "not json", 0); _bugsee_stop(7, OnStopped); expect(waitFor(^{ return gStoppedToken == 7 ? 1 : 0; }, 5), "stop reports completion with its token"); _bugsee_relaunch(NULL, 42, OnStarted); From f2ff17d176ba2572afa42f7cc1747ccdf2fa487a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:15:42 +0500 Subject: [PATCH 113/157] Document the secure-rectangle origin (top-left on both platforms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both native SDKs take a top-left-origin rectangle (Android display pixels, iOS points, verified against the 7.4.1 AAR and the iOS 7.0 headers). The wrapper passes RectInt through unchanged; document that, and how to convert from Unity's bottom-left screen space. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 ++ Documentation~/index.md | 18 ++++++++++++++++++ Runtime/Bugsee.cs | 11 +++++++++++ 3 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68332de..b043b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. + - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. - Android Mono builds cannot symbolicate native frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`): Unity 2021.3 has no Mono ARM64 and those libraries have no GNU build-id. IL2CPP is the supported configuration for fully symbolicated native crashes. Documented in the Player Settings checklist, and a Mono Android build now logs a one-time warning at build time regardless of symbol upload settings (the build is never failed). diff --git a/Documentation~/index.md b/Documentation~/index.md index 4530022..8ef8c81 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -205,6 +205,24 @@ Device/CI proof remains manual. Managed error/crash **grouping** (one signature per event, Unity-primary when strong): [exception-signatures.md](exception-signatures.md). +### Secure rectangles: coordinate origin + +`Bugsee.AddSecureRectangle(RectInt)` and `RemoveSecureRectangle(RectInt)` take screen pixels with the origin at the **top-left** of the screen, y growing downward. This is what both native SDKs expect (Android `Rect` in display pixels; iOS points with a top-left origin, which the wrapper derives by dividing by the screen scale). The wrapper passes the rectangle through as given and does not flip it. Unity's `Screen`, `Input.mousePosition` and `Rect` from screen-space APIs use a **bottom-left** origin, so convert before adding: + +```csharp +// Unity screen rect (bottom-left origin) -> rect for AddSecureRectangle (top-left origin). +static RectInt ToSecureRect(Rect unityScreenRect) +{ + int x = Mathf.FloorToInt(unityScreenRect.xMin); + int y = Mathf.FloorToInt(Screen.height - unityScreenRect.yMax); + int w = Mathf.CeilToInt(unityScreenRect.xMax) - x; + int h = Mathf.CeilToInt(Screen.height - unityScreenRect.yMin) - y; + return new RectInt(x, y, w, h); +} +``` + +A `RectTransform` corner from `GetWorldCorners` in a screen-space overlay canvas is also bottom-left: build the `Rect` from those corners, then convert. Rectangles are not flipped on rotation or resize; add them again when the screen size changes. A rectangle is removed with the same top-left rectangle it was added with. + ## Native dependencies - **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1`, declared in the generated Gradle project by `Editor/BugseeAndroidMavenDependencies.cs` (Gradle plugin `4.0.8`). Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 0665a2c..f3e74f9 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -291,6 +291,13 @@ public static void SetUserIdentifier(string userIdentifier) => public static void ClearAllAttributes() => Bridge.ClearAllAttributes(); + /// + /// Hides a screen region in captured video and report screenshots. + /// Coordinates are screen pixels with the origin at the TOP-LEFT of the screen, as both native SDKs + /// expect (Android pixels; iOS points, converted by the screen scale). The rectangle is passed through + /// as given: it is NOT flipped from Unity's bottom-left Screen/Input space. Convert with + /// y = Screen.height - rect.yMax; see the package documentation. + /// public static void AddSecureRectangle(RectInt pixelRect) { _manualSecureRectOwners.Add( @@ -303,6 +310,10 @@ public static void AddSecureRectangle(RectInt pixelRect) PushSecureBufferForDisplay(DefaultSecureRectDisplayId); } + /// + /// Stops hiding a rectangle added with . It must be the same + /// top-left-origin rectangle that was added (see ). + /// public static void RemoveSecureRectangle(RectInt pixelRect) { if (!_manualSecureRectOwners.Remove( From f52ae7191048869e17c7a78021c161d7aae1bd15 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:17:12 +0500 Subject: [PATCH 114/157] Expose APM transactions and spans on Android and iOS. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugsee.StartTransaction returns a managed ISpan over an opaque native handle (a Java object on Android, a registry id on iOS). Finishing a span finishes its live children as Cancelled, finishes it natively and releases the handle exactly once; the lifetime and argument rules live in the pure-C# ManagedSpan and SpanPolicy and are unit tested against a fake handle. No global StartSpan/GetActiveSpan. Fixes #72 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + DESIGN.md | 2 +- Documentation~/index.md | 17 +- Plugins/iOS/BugseeUnityBridge.mm | 117 +++++++++ Runtime/Bugsee.cs | 28 +++ Runtime/Contracts/Apm.meta | 8 + Runtime/Contracts/Apm/ISpan.cs | 43 ++++ Runtime/Contracts/Apm/ISpan.cs.meta | 11 + Runtime/Contracts/Apm/SpanStatus.cs | 16 ++ Runtime/Contracts/Apm/SpanStatus.cs.meta | 11 + Runtime/Platform/Android/AndroidBridge.cs | 10 + Runtime/Platform/Android/AndroidSpanHandle.cs | 90 +++++++ .../Android/AndroidSpanHandle.cs.meta | 11 + Runtime/Platform/Editor/EditorBridge.cs | 1 + Runtime/Platform/IBugseeNativeBridge.cs | 4 + Runtime/Platform/IOS/IOSBridge.cs | 6 + Runtime/Platform/IOS/IosSpanHandle.cs | 80 ++++++ Runtime/Platform/IOS/IosSpanHandle.cs.meta | 11 + Runtime/WrapperPolicy/ISpanHandle.cs | 27 ++ Runtime/WrapperPolicy/ISpanHandle.cs.meta | 11 + Runtime/WrapperPolicy/ManagedSpan.cs | 207 +++++++++++++++ Runtime/WrapperPolicy/ManagedSpan.cs.meta | 11 + Runtime/WrapperPolicy/SpanPolicy.cs | 54 ++++ Runtime/WrapperPolicy/SpanPolicy.cs.meta | 11 + .../Bugsee.WrapperPolicy.Tests.csproj | 1 + Tests~/WrapperPolicy/ManagedSpanTests.cs | 235 ++++++++++++++++++ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 40 +++ Tests~/WrapperPolicy/SpanPolicyTests.cs | 64 +++++ Tests~/iOS/BridgeTests.mm | 47 ++++ 29 files changed, 1172 insertions(+), 3 deletions(-) create mode 100644 Runtime/Contracts/Apm.meta create mode 100644 Runtime/Contracts/Apm/ISpan.cs create mode 100644 Runtime/Contracts/Apm/ISpan.cs.meta create mode 100644 Runtime/Contracts/Apm/SpanStatus.cs create mode 100644 Runtime/Contracts/Apm/SpanStatus.cs.meta create mode 100644 Runtime/Platform/Android/AndroidSpanHandle.cs create mode 100644 Runtime/Platform/Android/AndroidSpanHandle.cs.meta create mode 100644 Runtime/Platform/IOS/IosSpanHandle.cs create mode 100644 Runtime/Platform/IOS/IosSpanHandle.cs.meta create mode 100644 Runtime/WrapperPolicy/ISpanHandle.cs create mode 100644 Runtime/WrapperPolicy/ISpanHandle.cs.meta create mode 100644 Runtime/WrapperPolicy/ManagedSpan.cs create mode 100644 Runtime/WrapperPolicy/ManagedSpan.cs.meta create mode 100644 Runtime/WrapperPolicy/SpanPolicy.cs create mode 100644 Runtime/WrapperPolicy/SpanPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/ManagedSpanTests.cs create mode 100644 Tests~/WrapperPolicy/SpanPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68332de..45e9414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. +- `Bugsee.StartTransaction(name, operation, attributes)` and `ISpan` (`StartChild`, `SetName`, `SetDescription`, `SetAttribute`, `SetStatus`, `Finish`, `Dispose`) for APM on Android and iOS. A span is a managed wrapper over a native handle that is released when the span finishes (children still live are finished as `Cancelled` first); there is no global `StartSpan`/`GetActiveSpan`. - Initial UPM package scaffold (`com.bugsee.unity`). - Android native SDK from Maven Central (`com.bugsee:bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1`), declared in the generated Gradle project at build time. Installing is just adding the package: no EDM4U, no OpenUPM scoped registry, no resolve step, no custom Gradle templates. A Bugsee dependency the project already declares (EDM4U, custom template, local AAR) is kept, and a different version is reported. Core AAR publishes no transitives; NDK is declared explicitly. - Android Gradle plugin `4.0.8` (NDK enabled on Unity 6+ launcher templates). diff --git a/DESIGN.md b/DESIGN.md index 66af7dc..5de7fc8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -179,7 +179,7 @@ Identity: `SetUserIdentifier` / `Get` / `Clear` (not email-as-primary) Attributes: set/get/clear/clearAll/getAll Appearance: `GetAppearance()` Video: `GetVideoFrameConsumer` (internal to capturer); `ResetVideoCapturePermission` -APM: `StartTransaction`, `StartSpan`, `GetActiveSpan` +APM: `StartTransaction` returning a managed `ISpan` with child spans (no global `StartSpan` / `GetActiveSpan`: the native active span is per thread) Feedback: `Bugsee.Feedback` → show UI, greeting, listener Wrapper: **internal registration only** — not an app-facing `SetWrapper` diff --git a/Documentation~/index.md b/Documentation~/index.md index 4530022..a89e34a 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -224,8 +224,21 @@ Wired: Launch/Stop, blackout, log/trace/event, exceptions (JSON-in-reason), Test `Bugsee.Notify(title, body = null, severity = IssueSeverity.Medium, fields = null, urgent = false)` relays a lightweight notification to the app's messaging integrations without creating a report (no video, logs or events). It is stored offline and sent in batches; `urgent` asks for immediate delivery. A call with an empty title, or before the SDK is launched, is ignored by the native SDK (the title case also logs a Unity warning). `fields` are extra string pairs; entries with a null key or value are dropped. The severity is always sent, so the native default for the shorter overloads is not used. -### APM (not exposed yet) +### Performance transactions and spans (APM) -Both native SDKs have `startTransaction` / `startSpan` / `getActiveSpan`, but the Unity package does not expose them yet: a span is a live native object (it needs a handle that crosses JNI and P/Invoke and a lifetime rule), the active span is per native thread (not propagated across `dispatch_async` on iOS), and the no-op behaviour when APM is disabled is only documented on iOS. See the proposal on the tracking issue. +```csharp +using (var checkout = Bugsee.StartTransaction("checkout", "user.flow", new Dictionary { ["cart"] = 3 })) +using (var pay = checkout.StartChild("payment", "charge card")) +{ + pay.SetAttribute("provider", "stripe"); + // ... work ... + pay.Finish(); // Dispose without Finish also finishes with Ok + checkout.SetStatus(SpanStatus.Ok); +} +``` + +`Bugsee.StartTransaction(name, operation, attributes = null)` returns an `ISpan` (`SpanId`, `TraceId`, `Operation`, `IsFinished`, `SetName`, `SetDescription`, `SetAttribute`, `SetStatus`, `StartChild(operation, description = null)`, `Finish()` / `Finish(SpanStatus)`, `Dispose` = `Finish()`). Attribute values follow the `SetAttribute` rules; a rejected value is dropped with a Unity warning (spans never throw). The statuses are `Ok`, `Error`, `Timeout`, `Cancelled`, `DeadlineExceeded` and `Unknown`. + +A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids) while the SDK is not launched or when the name or operation is empty; iOS documents a shared no-op span when performance monitoring is off (Android's behaviour is not documented; a null result from it is also treated as inert), so the wrapper never reports a failure. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). Android threading of a span is not documented by the native SDK, so use a span from the thread that created it unless you serialize access yourself. Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index d427eeb..f2c3996 100644 --- a/Plugins/iOS/BugseeUnityBridge.mm +++ b/Plugins/iOS/BugseeUnityBridge.mm @@ -497,6 +497,113 @@ void _bugsee_notify(const char *title, const char *body, int severity, const cha urgent:urgent != 0]; } +// APM spans are live SDK objects, so managed code holds them by id. An entry lives from the span's creation until +// _bugsee_span_release, which the managed wrapper calls exactly once after finishing the span (and its finalizer +// calls for a span that was dropped unfinished). Ids start at 1 and are never reused; 0 means "no span". +static NSMutableDictionary> *BugseeSpanRegistry(void) +{ + static NSMutableDictionary> *registry; + static dispatch_once_t once; + dispatch_once(&once, ^{ + registry = [NSMutableDictionary dictionary]; + }); + return registry; +} + +static int64_t BugseeRegisterSpan(id span) +{ + if (!span) { + return 0; + } + static int64_t nextId = 0; + NSMutableDictionary> *registry = BugseeSpanRegistry(); + @synchronized (registry) { + int64_t spanId = ++nextId; + registry[@(spanId)] = span; + return spanId; + } +} + +static id BugseeLookupSpan(int64_t spanId) +{ + NSMutableDictionary> *registry = BugseeSpanRegistry(); + @synchronized (registry) { + return registry[@(spanId)]; + } +} + +int64_t _bugsee_start_transaction(const char *name, const char *operation, const char *attributesJson) +{ + NSString *nameStr = BugseeNSString(name); + NSString *operationStr = BugseeNSString(operation); + if (nameStr.length == 0 || operationStr.length == 0) { + return 0; + } + id parsed = BugseeDeserializeJson(attributesJson); + NSDictionary *attributes = [parsed isKindOfClass:[NSDictionary class]] ? parsed : nil; + return BugseeRegisterSpan([Bugsee startTransactionWithName:nameStr operation:operationStr attributes:attributes]); +} + +int64_t _bugsee_span_start_child(int64_t spanId, const char *operation, const char *description) +{ + id span = BugseeLookupSpan(spanId); + NSString *operationStr = BugseeNSString(operation); + if (!span || operationStr.length == 0) { + return 0; + } + return BugseeRegisterSpan([span startChildSpanWithOperation:operationStr description:BugseeNSString(description)]); +} + +char *_bugsee_span_get_id(int64_t spanId) +{ + return BugseeCopyUTF8([BugseeLookupSpan(spanId) spanId]); +} + +char *_bugsee_span_get_trace_id(int64_t spanId) +{ + return BugseeCopyUTF8([BugseeLookupSpan(spanId) traceId]); +} + +void _bugsee_span_set_name(int64_t spanId, const char *name) +{ + NSString *nameStr = BugseeNSString(name); + if (nameStr.length > 0) { + [BugseeLookupSpan(spanId) setName:nameStr]; + } +} + +void _bugsee_span_set_description(int64_t spanId, const char *description) +{ + [BugseeLookupSpan(spanId) setSpanDescription:BugseeNSString(description)]; +} + +void _bugsee_span_set_attribute(int64_t spanId, const char *key, const char *valueJson) +{ + NSString *keyStr = BugseeNSString(key); + id value = valueJson ? BugseeDeserializeJson(valueJson) : nil; + if (keyStr.length > 0 && value && value != [NSNull null]) { + [BugseeLookupSpan(spanId) setAttribute:keyStr value:value]; + } +} + +void _bugsee_span_set_status(int64_t spanId, int status) +{ + [BugseeLookupSpan(spanId) setStatus:(BGSSpanStatus)status]; +} + +void _bugsee_span_finish(int64_t spanId, int status) +{ + [BugseeLookupSpan(spanId) finishWithStatus:(BGSSpanStatus)status]; +} + +void _bugsee_span_release(int64_t spanId) +{ + NSMutableDictionary> *registry = BugseeSpanRegistry(); + @synchronized (registry) { + [registry removeObjectForKey:@(spanId)]; + } +} + void _bugsee_set_attribute(const char *key, const char *valueJson) { id value = valueJson ? BugseeDeserializeJson(valueJson) : nil; @@ -889,6 +996,16 @@ void _bugsee_test_crash(void) {} void _bugsee_upload(const char *summary, const char *description, int severity, const char *labelsJson) { (void)summary; (void)description; (void)severity; (void)labelsJson; } char *_bugsee_get_launch_options(void) { return NULL; } void _bugsee_notify(const char *title, const char *body, int severity, const char *fieldsJson, int urgent) { (void)title; (void)body; (void)severity; (void)fieldsJson; (void)urgent; } +int64_t _bugsee_start_transaction(const char *name, const char *operation, const char *attributesJson) { (void)name; (void)operation; (void)attributesJson; return 0; } +int64_t _bugsee_span_start_child(int64_t spanId, const char *operation, const char *description) { (void)spanId; (void)operation; (void)description; return 0; } +char *_bugsee_span_get_id(int64_t spanId) { (void)spanId; return NULL; } +char *_bugsee_span_get_trace_id(int64_t spanId) { (void)spanId; return NULL; } +void _bugsee_span_set_name(int64_t spanId, const char *name) { (void)spanId; (void)name; } +void _bugsee_span_set_description(int64_t spanId, const char *description) { (void)spanId; (void)description; } +void _bugsee_span_set_attribute(int64_t spanId, const char *key, const char *valueJson) { (void)spanId; (void)key; (void)valueJson; } +void _bugsee_span_set_status(int64_t spanId, int status) { (void)spanId; (void)status; } +void _bugsee_span_finish(int64_t spanId, int status) { (void)spanId; (void)status; } +void _bugsee_span_release(int64_t spanId) { (void)spanId; } void _bugsee_set_attribute(const char *key, const char *valueJson) { (void)key; (void)valueJson; } char *_bugsee_get_attribute(const char *key) { (void)key; return NULL; } void _bugsee_clear_attribute(const char *key) { (void)key; } diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 0665a2c..af2d950 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; +using Bugsee.Contracts.Apm; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; using Bugsee.Contracts.Feedback; @@ -276,6 +277,33 @@ public static void Notify( Bridge.Notify(title, body, severity, NotifyPolicy.CleanFields(fields), urgent); } + /// + /// Starts a performance transaction, the root of a trace; start child spans with + /// and finish (or dispose) every span. Spans are always created from a parent: there is no global "current + /// span", because the native SDKs track it per thread. Returns an inert span that accepts every call when the + /// SDK is not launched, when or is empty, or when + /// performance monitoring is off. take the same values as + /// ; invalid entries are dropped with a warning. + /// + public static ISpan StartTransaction(string name, string operation, IDictionary attributes = null) + { + if (!SpanPolicy.HasText(name) || !SpanPolicy.HasText(operation)) + { + Debug.LogWarning("[Bugsee] StartTransaction ignored: a name and an operation are required."); + return NoopSpan.Instance; + } + if (!Bridge.GetLaunched()) return NoopSpan.Instance; + + var rejected = new List(); + var clean = SpanPolicy.CleanAttributes(attributes, rejected); + foreach (var reason in rejected) Debug.LogWarning("[Bugsee] StartTransaction attribute dropped: " + reason + "."); + + var handle = Bridge.StartTransaction(name, operation, clean); + return handle == null + ? NoopSpan.Instance + : new ManagedSpan(handle, operation, message => Debug.LogWarning("[Bugsee] " + message)); + } + public static void SetUserIdentifier(string userIdentifier) => Bridge.SetUserIdentifier(userIdentifier); diff --git a/Runtime/Contracts/Apm.meta b/Runtime/Contracts/Apm.meta new file mode 100644 index 0000000..c1e1c5d --- /dev/null +++ b/Runtime/Contracts/Apm.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 214a106fb1384449afc9c2f229f9a963 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Contracts/Apm/ISpan.cs b/Runtime/Contracts/Apm/ISpan.cs new file mode 100644 index 0000000..43819a2 --- /dev/null +++ b/Runtime/Contracts/Apm/ISpan.cs @@ -0,0 +1,43 @@ +using System; + +namespace Bugsee.Contracts.Apm +{ + /// + /// A unit of work in a performance trace, started with Bugsee.StartTransaction or . + /// It wraps a native span: finish it (or dispose it) to release the native object. Finishing a span first + /// finishes its still-live children as . Everything is safe to call after + /// (it does nothing), and a span started while the SDK is not launched or without a + /// valid name is an inert span that accepts every call. Setters that take invalid input log a warning and + /// leave the span unchanged rather than throw. + /// + public interface ISpan : IDisposable + { + /// The native span id, or an empty string for an inert span. + string SpanId { get; } + + /// The id of the trace the span belongs to, or an empty string for an inert span. + string TraceId { get; } + + string Operation { get; } + + /// True once the span was finished, directly or because its parent finished. + bool IsFinished { get; } + + ISpan SetName(string name); + + ISpan SetDescription(string description); + + /// The value must be a string (up to 1024 UTF-16 units), a bool or a number in the signed 64-bit range. + ISpan SetAttribute(string key, object value); + + ISpan SetStatus(SpanStatus status); + + /// Starts a child span. A finished parent returns an inert span. + ISpan StartChild(string operation, string description = null); + + /// Finishes the span with . + void Finish(); + + void Finish(SpanStatus status); + } +} diff --git a/Runtime/Contracts/Apm/ISpan.cs.meta b/Runtime/Contracts/Apm/ISpan.cs.meta new file mode 100644 index 0000000..911fe2f --- /dev/null +++ b/Runtime/Contracts/Apm/ISpan.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 03b02b7ab8924cb6bef68854ab1127c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Contracts/Apm/SpanStatus.cs b/Runtime/Contracts/Apm/SpanStatus.cs new file mode 100644 index 0000000..1283301 --- /dev/null +++ b/Runtime/Contracts/Apm/SpanStatus.cs @@ -0,0 +1,16 @@ +namespace Bugsee.Contracts.Apm +{ + /// + /// Terminal status of a span. The values are the iOS SDK's wire values; Android is mapped by name + /// (see SpanPolicy.AndroidStatusName). + /// + public enum SpanStatus + { + Ok = 0, + Error = 1, + Timeout = 2, + Cancelled = 3, + DeadlineExceeded = 4, + Unknown = 5 + } +} diff --git a/Runtime/Contracts/Apm/SpanStatus.cs.meta b/Runtime/Contracts/Apm/SpanStatus.cs.meta new file mode 100644 index 0000000..6195242 --- /dev/null +++ b/Runtime/Contracts/Apm/SpanStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21e85abc920b40319e3640641e92736f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index b135f3a..ca377b0 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -355,6 +355,16 @@ public void Notify(string title, string body, IssueSeverity severity, IDictionar } } + public ISpanHandle StartTransaction(string name, string operation, IDictionary attributes) + { + // The three-argument overload always takes a map (a null cannot select an overload), empty when there are none. + using (var javaAttributes = AndroidJavaConverters.ObjectDictionaryToMap(attributes)) + { + var transaction = _bugsee.CallStatic("startTransaction", name, operation, javaAttributes); + return transaction == null ? null : new AndroidSpanHandle(transaction); + } + } + public void SetUserIdentifier(string userIdentifier) { var normalized = UserIdentifierPolicy.ForSet(userIdentifier); diff --git a/Runtime/Platform/Android/AndroidSpanHandle.cs b/Runtime/Platform/Android/AndroidSpanHandle.cs new file mode 100644 index 0000000..1b9b439 --- /dev/null +++ b/Runtime/Platform/Android/AndroidSpanHandle.cs @@ -0,0 +1,90 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using Bugsee.Contracts.Apm; +using Bugsee.WrapperPolicy; +using UnityEngine; + +namespace Bugsee.Platform.Android +{ + /// + /// One com.bugsee.library.contracts.performance.Span. The handle is the held + /// (a JNI global reference), disposed by once is done with it. + /// + sealed class AndroidSpanHandle : ISpanHandle + { + const string StatusClass = "com.bugsee.library.contracts.performance.SpanStatus"; + + AndroidJavaObject _span; + + public AndroidSpanHandle(AndroidJavaObject span) + { + _span = span ?? throw new ArgumentNullException(nameof(span)); + try + { + SpanId = span.Call("getSpanId") ?? ""; + TraceId = span.Call("getTraceId") ?? ""; + } + catch + { + Release(); + throw; + } + } + + public string SpanId { get; } + public string TraceId { get; } + + public void SetName(string name) => Chain("setName", name); + + public void SetDescription(string description) => Chain("setDescription", description); + + public void SetAttribute(string key, object value) + { + using (var boxed = AndroidJavaConverters.Box(value)) + Chain("setAttribute", key, boxed); + } + + public void SetStatus(SpanStatus status) + { + using (var native = NativeStatus(status)) + Chain("setStatus", native); + } + + public ISpanHandle StartChild(string operation, string description) + { + AndroidJNI.AttachCurrentThread(); + // A null cannot select a JNI overload, so no description uses the one-argument method. + var child = description == null + ? _span.Call("startChildSpan", operation) + : _span.Call("startChildSpan", operation, description); + return child == null ? null : new AndroidSpanHandle(child); + } + + public void Finish(SpanStatus status) + { + AndroidJNI.AttachCurrentThread(); + using (var native = NativeStatus(status)) + _span.Call("finish", native); + } + + public void Release() + { + _span?.Dispose(); + _span = null; + } + + // The setters return the span for chaining; each call returns a new reference that must be disposed. + void Chain(string method, params object[] args) + { + AndroidJNI.AttachCurrentThread(); + using (_span.Call(method, args)) { } + } + + static AndroidJavaObject NativeStatus(SpanStatus status) + { + using (var clazz = new AndroidJavaClass(StatusClass)) + return clazz.GetStatic(SpanPolicy.AndroidStatusName(status)); + } + } +} +#endif diff --git a/Runtime/Platform/Android/AndroidSpanHandle.cs.meta b/Runtime/Platform/Android/AndroidSpanHandle.cs.meta new file mode 100644 index 0000000..d691752 --- /dev/null +++ b/Runtime/Platform/Android/AndroidSpanHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1414204298694d0baf8ba77a5c2f5580 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index b2d5e38..59cdb00 100644 --- a/Runtime/Platform/Editor/EditorBridge.cs +++ b/Runtime/Platform/Editor/EditorBridge.cs @@ -47,6 +47,7 @@ public void TestCrash() { } public void ShowReportDialog(string summary, string description, IssueSeverity severity, IList labels) { } public void Upload(string summary, string description, IssueSeverity severity, IList labels) { } public void Notify(string title, string body, IssueSeverity severity, IDictionary fields, bool urgent) { } + public ISpanHandle StartTransaction(string name, string operation, IDictionary attributes) => null; public void SetUserIdentifier(string userIdentifier) { } public string GetUserIdentifier() => null; public void ClearUserIdentifier() { } diff --git a/Runtime/Platform/IBugseeNativeBridge.cs b/Runtime/Platform/IBugseeNativeBridge.cs index 6dda9d9..71b17fb 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -7,6 +7,7 @@ using Bugsee.Contracts.Options; using Bugsee.Contracts.Reporting; using Bugsee.Internal; +using Bugsee.WrapperPolicy; namespace Bugsee.Platform { @@ -51,6 +52,9 @@ public interface IBugseeNativeBridge /// Relay a lightweight notification to the app's messaging integrations (no report is created). void Notify(string title, string body, IssueSeverity severity, IDictionary fields, bool urgent); + /// Starts a native transaction (attributes already cleaned, may be null), or null when there is none. + ISpanHandle StartTransaction(string name, string operation, IDictionary attributes); + void SetUserIdentifier(string userIdentifier); string GetUserIdentifier(); void ClearUserIdentifier(); diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index d0ca501..569509c 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -665,6 +665,12 @@ public void Notify(string title, string body, IssueSeverity severity, IDictionar _bugsee_notify(title ?? "", NotifyPolicy.Body(body), (int)severity, ToJsonObject(NotifyPolicy.ToWireMap(fields)), urgent ? 1 : 0); + public ISpanHandle StartTransaction(string name, string operation, IDictionary attributes) + { + long id = IosSpanHandle._bugsee_start_transaction(name, operation, ToJsonObject(attributes)); + return id == 0 ? null : new IosSpanHandle(id); + } + public void SetUserIdentifier(string userIdentifier) { var normalized = UserIdentifierPolicy.ForSet(userIdentifier); diff --git a/Runtime/Platform/IOS/IosSpanHandle.cs b/Runtime/Platform/IOS/IosSpanHandle.cs new file mode 100644 index 0000000..9fe553a --- /dev/null +++ b/Runtime/Platform/IOS/IosSpanHandle.cs @@ -0,0 +1,80 @@ +#if UNITY_IOS && !UNITY_EDITOR +using System; +using System.Runtime.InteropServices; +using Bugsee.Contracts.Apm; +using Bugsee.WrapperPolicy; + +namespace Bugsee.Platform.IOS +{ + /// + /// One id<BGSSpan> held by id in the native registry (BugseeUnityBridge.mm). + /// removes the registry entry once is done with the span; the finalizer does the same for + /// a handle that was dropped without being released, so the registry never outlives the managed object. + /// + sealed class IosSpanHandle : ISpanHandle + { + long _id; + + public IosSpanHandle(long id) + { + _id = id; + SpanId = Read(_bugsee_span_get_id(id)); + TraceId = Read(_bugsee_span_get_trace_id(id)); + } + + ~IosSpanHandle() => Release(); + + public string SpanId { get; } + public string TraceId { get; } + + public void SetName(string name) => _bugsee_span_set_name(_id, name); + + public void SetDescription(string description) => _bugsee_span_set_description(_id, description); + + public void SetAttribute(string key, object value) => + _bugsee_span_set_attribute(_id, key, AttributeWire.ToJson(value)); + + public void SetStatus(SpanStatus status) => _bugsee_span_set_status(_id, (int)status); + + public ISpanHandle StartChild(string operation, string description) + { + long child = _bugsee_span_start_child(_id, operation, description); + return child == 0 ? null : new IosSpanHandle(child); + } + + public void Finish(SpanStatus status) => _bugsee_span_finish(_id, (int)status); + + public void Release() + { + long id = System.Threading.Interlocked.Exchange(ref _id, 0); + if (id != 0) _bugsee_span_release(id); + GC.SuppressFinalize(this); + } + + static string Read(IntPtr ptr) + { + if (ptr == IntPtr.Zero) return ""; + try + { + return Marshal.PtrToStringAnsi(ptr) ?? ""; + } + finally + { + _bugsee_free(ptr); + } + } + + [DllImport("__Internal")] internal static extern long _bugsee_start_transaction(string name, string operation, string attributesJson); + [DllImport("__Internal")] static extern long _bugsee_span_start_child(long spanId, string operation, string description); + [DllImport("__Internal")] static extern IntPtr _bugsee_span_get_id(long spanId); + [DllImport("__Internal")] static extern IntPtr _bugsee_span_get_trace_id(long spanId); + [DllImport("__Internal")] static extern void _bugsee_span_set_name(long spanId, string name); + [DllImport("__Internal")] static extern void _bugsee_span_set_description(long spanId, string description); + [DllImport("__Internal")] static extern void _bugsee_span_set_attribute(long spanId, string key, string valueJson); + [DllImport("__Internal")] static extern void _bugsee_span_set_status(long spanId, int status); + [DllImport("__Internal")] static extern void _bugsee_span_finish(long spanId, int status); + [DllImport("__Internal")] static extern void _bugsee_span_release(long spanId); + [DllImport("__Internal")] static extern void _bugsee_free(IntPtr ptr); + } +} +#endif diff --git a/Runtime/Platform/IOS/IosSpanHandle.cs.meta b/Runtime/Platform/IOS/IosSpanHandle.cs.meta new file mode 100644 index 0000000..fa696e6 --- /dev/null +++ b/Runtime/Platform/IOS/IosSpanHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 405ed438fb084a28b50596f6160cf30f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/ISpanHandle.cs b/Runtime/WrapperPolicy/ISpanHandle.cs new file mode 100644 index 0000000..3fcb8e0 --- /dev/null +++ b/Runtime/WrapperPolicy/ISpanHandle.cs @@ -0,0 +1,27 @@ +using Bugsee.Contracts.Apm; + +namespace Bugsee.WrapperPolicy +{ + /// + /// One native span as a platform bridge exposes it (a Java object on Android, an id into the native registry on + /// iOS). owns it: it calls at most once and + /// exactly once, in that order, and never touches it afterwards. + /// + public interface ISpanHandle + { + string SpanId { get; } + string TraceId { get; } + void SetName(string name); + void SetDescription(string description); + void SetAttribute(string key, object value); + void SetStatus(SpanStatus status); + + /// A new child handle, or null when the native SDK produced none. The description may be null. + ISpanHandle StartChild(string operation, string description); + + void Finish(SpanStatus status); + + /// Drops the wrapper's reference to the native span (a JNI global ref, a registry entry). + void Release(); + } +} diff --git a/Runtime/WrapperPolicy/ISpanHandle.cs.meta b/Runtime/WrapperPolicy/ISpanHandle.cs.meta new file mode 100644 index 0000000..31f9672 --- /dev/null +++ b/Runtime/WrapperPolicy/ISpanHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c202ee1455744b99a2dd72dcb76c0b41 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/ManagedSpan.cs b/Runtime/WrapperPolicy/ManagedSpan.cs new file mode 100644 index 0000000..f322f1c --- /dev/null +++ b/Runtime/WrapperPolicy/ManagedSpan.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Apm; + +namespace Bugsee.WrapperPolicy +{ + /// + /// The managed span: all lifetime and argument rules around one . A span is finished + /// once; that finishes its live children as Cancelled first (so the result does not depend on whether the native + /// SDK cascades), calls the native finish, then releases the handle. After that no native call is made. Both + /// the finish and the release run even if a native call throws. Thread-safe: the state is guarded and the + /// handle is only used while the span is live. + /// + public sealed class ManagedSpan : ISpan + { + readonly object _gate = new object(); + readonly string _operation; + readonly Action _warn; + readonly ManagedSpan _parent; + readonly List _children = new List(); + ISpanHandle _handle; + bool _finished; + + public ManagedSpan(ISpanHandle handle, string operation, Action warn = null) + : this(handle, operation, warn, null) + { + } + + ManagedSpan(ISpanHandle handle, string operation, Action warn, ManagedSpan parent) + { + _handle = handle ?? throw new ArgumentNullException(nameof(handle)); + _operation = operation ?? ""; + _warn = warn; + _parent = parent; + SpanId = handle.SpanId ?? ""; + TraceId = handle.TraceId ?? ""; + } + + public string SpanId { get; } + public string TraceId { get; } + public string Operation => _operation; + + public bool IsFinished + { + get { lock (_gate) return _finished; } + } + + public ISpan SetName(string name) + { + if (!SpanPolicy.HasText(name)) + { + Warn("SetName ignored: a name is required."); + return this; + } + lock (_gate) + { + if (!_finished) _handle.SetName(name); + } + return this; + } + + public ISpan SetDescription(string description) + { + lock (_gate) + { + if (!_finished) _handle.SetDescription(SpanPolicy.Description(description)); + } + return this; + } + + public ISpan SetAttribute(string key, object value) + { + var decision = AttributePolicy.Evaluate(key, value); + if (!decision.Accepted) + { + Warn("SetAttribute ignored: " + decision.Error + "."); + return this; + } + lock (_gate) + { + if (!_finished) _handle.SetAttribute(key, AttributeWire.ToBoxable(value)); + } + return this; + } + + public ISpan SetStatus(SpanStatus status) + { + lock (_gate) + { + if (!_finished) _handle.SetStatus(SpanPolicy.Normalize(status)); + } + return this; + } + + public ISpan StartChild(string operation, string description = null) + { + if (!SpanPolicy.HasText(operation)) + { + Warn("StartChild ignored: an operation is required."); + return NoopSpan.Instance; + } + lock (_gate) + { + if (_finished) return NoopSpan.Instance; + var childHandle = _handle.StartChild(operation, description); + if (childHandle == null) return NoopSpan.Instance; + var child = new ManagedSpan(childHandle, operation, _warn, this); + _children.Add(child); + return child; + } + } + + public void Finish() => Finish(SpanStatus.Ok); + + public void Finish(SpanStatus status) + { + ManagedSpan[] children; + ISpanHandle handle; + lock (_gate) + { + if (_finished) return; + _finished = true; + handle = _handle; + _handle = null; + children = _children.ToArray(); + _children.Clear(); + } + + try + { + // A child's own finish removes it from this list; the snapshot is already detached. + foreach (var child in children) child.FinishFromParent(); + handle.Finish(SpanPolicy.Normalize(status)); + } + finally + { + handle.Release(); + _parent?.Forget(this); + } + } + + public void Dispose() => Finish(); + + void FinishFromParent() + { + // The parent already detached this child, so it must not call back into the parent's list. + ISpanHandle handle; + lock (_gate) + { + if (_finished) return; + _finished = true; + handle = _handle; + _handle = null; + } + var grandChildren = DetachChildren(); + try + { + foreach (var child in grandChildren) child.FinishFromParent(); + handle.Finish(SpanStatus.Cancelled); + } + finally + { + handle.Release(); + } + } + + ManagedSpan[] DetachChildren() + { + lock (_gate) + { + var children = _children.ToArray(); + _children.Clear(); + return children; + } + } + + void Forget(ManagedSpan child) + { + lock (_gate) _children.Remove(child); + } + + void Warn(string message) => _warn?.Invoke(message); + } + + /// The span returned when there is nothing native behind it: every call is accepted and does nothing. + public sealed class NoopSpan : ISpan + { + public static readonly NoopSpan Instance = new NoopSpan(); + + NoopSpan() + { + } + + public string SpanId => ""; + public string TraceId => ""; + public string Operation => ""; + public bool IsFinished => false; + public ISpan SetName(string name) => this; + public ISpan SetDescription(string description) => this; + public ISpan SetAttribute(string key, object value) => this; + public ISpan SetStatus(SpanStatus status) => this; + public ISpan StartChild(string operation, string description = null) => this; + public void Finish() { } + public void Finish(SpanStatus status) { } + public void Dispose() { } + } +} diff --git a/Runtime/WrapperPolicy/ManagedSpan.cs.meta b/Runtime/WrapperPolicy/ManagedSpan.cs.meta new file mode 100644 index 0000000..08d6583 --- /dev/null +++ b/Runtime/WrapperPolicy/ManagedSpan.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a5c8fd7fdc543a6adee865e7f874368 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/SpanPolicy.cs b/Runtime/WrapperPolicy/SpanPolicy.cs new file mode 100644 index 0000000..441ebba --- /dev/null +++ b/Runtime/WrapperPolicy/SpanPolicy.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Apm; + +namespace Bugsee.WrapperPolicy +{ + /// Argument rules for transactions and spans, shared by both bridges. + public static class SpanPolicy + { + public static bool HasText(string value) => !string.IsNullOrEmpty(value); + + /// Android takes a nullable description only through its one-argument overload, so null crosses as empty. + public static string Description(string description) => description ?? ""; + + public static SpanStatus Normalize(SpanStatus status) => + Enum.IsDefined(typeof(SpanStatus), status) ? status : SpanStatus.Unknown; + + /// The Android SpanStatus enum constant for a status. + public static string AndroidStatusName(SpanStatus status) + { + switch (Normalize(status)) + { + case SpanStatus.Ok: return "OK"; + case SpanStatus.Error: return "ERROR"; + case SpanStatus.Timeout: return "TIMEOUT"; + case SpanStatus.Cancelled: return "CANCELLED"; + case SpanStatus.DeadlineExceeded: return "DEADLINE_EXCEEDED"; + default: return "UNKNOWN"; + } + } + + /// + /// The entries of that accepts, or null when none + /// are left. Each rejected entry is described in (when given) instead of throwing. + /// + public static Dictionary CleanAttributes(IDictionary attributes, ICollection rejected = null) + { + if (attributes == null) return null; + Dictionary clean = null; + foreach (var kv in attributes) + { + var decision = AttributePolicy.Evaluate(kv.Key, kv.Value); + if (!decision.Accepted) + { + rejected?.Add(decision.Error); + continue; + } + if (clean == null) clean = new Dictionary(); + clean[kv.Key] = AttributeWire.ToBoxable(kv.Value); + } + return clean; + } + } +} diff --git a/Runtime/WrapperPolicy/SpanPolicy.cs.meta b/Runtime/WrapperPolicy/SpanPolicy.cs.meta new file mode 100644 index 0000000..04d95ff --- /dev/null +++ b/Runtime/WrapperPolicy/SpanPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 653a1aa86ffd42359ab284af9c96b154 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 08cf8b6..cf9f49c 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/Tests~/WrapperPolicy/ManagedSpanTests.cs b/Tests~/WrapperPolicy/ManagedSpanTests.cs new file mode 100644 index 0000000..76e9a0e --- /dev/null +++ b/Tests~/WrapperPolicy/ManagedSpanTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Apm; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class ManagedSpanTests + { + sealed class FakeHandle : ISpanHandle + { + public readonly List Calls; + public readonly List Children = new List(); + public bool ThrowOnFinish; + public bool ReturnNullChild; + readonly string _name; + + public FakeHandle(string name, List calls) + { + _name = name; + Calls = calls; + } + + public string SpanId => "span-" + _name; + public string TraceId => "trace"; + public void SetName(string name) => Calls.Add(_name + ":name=" + name); + public void SetDescription(string description) => Calls.Add(_name + ":desc=" + description); + public void SetAttribute(string key, object value) => Calls.Add(_name + ":attr " + key + "=" + string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}", value) + ":" + value.GetType().Name); + public void SetStatus(SpanStatus status) => Calls.Add(_name + ":status=" + status); + + public ISpanHandle StartChild(string operation, string description) + { + if (ReturnNullChild) return null; + var child = new FakeHandle(operation, Calls); + Children.Add(child); + Calls.Add(_name + ":child " + operation + " desc=" + (description ?? "")); + return child; + } + + public void Finish(SpanStatus status) + { + Calls.Add(_name + ":finish " + status); + if (ThrowOnFinish) throw new InvalidOperationException("native finish failed"); + } + + public void Release() => Calls.Add(_name + ":release"); + } + + static (ManagedSpan span, FakeHandle handle, List calls, List warnings) Make() + { + var calls = new List(); + var warnings = new List(); + var handle = new FakeHandle("root", calls); + return (new ManagedSpan(handle, "op", warnings.Add), handle, calls, warnings); + } + + [Test] + public void Ids_and_operation_are_read_once_from_the_handle() + { + var (span, _, _, _) = Make(); + Assert.That(span.SpanId, Is.EqualTo("span-root")); + Assert.That(span.TraceId, Is.EqualTo("trace")); + Assert.That(span.Operation, Is.EqualTo("op")); + Assert.That(span.IsFinished, Is.False); + } + + [Test] + public void Finish_finishes_then_releases_exactly_once() + { + var (span, _, calls, _) = Make(); + span.Finish(SpanStatus.Error); + span.Finish(); + span.Dispose(); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Error", "root:release" })); + Assert.That(span.IsFinished, Is.True); + } + + [Test] + public void Dispose_finishes_with_ok() + { + var (span, _, calls, _) = Make(); + span.Dispose(); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); + } + + [Test] + public void Calls_after_finish_do_not_reach_native() + { + var (span, _, calls, _) = Make(); + span.Finish(); + calls.Clear(); + span.SetName("n").SetDescription("d").SetAttribute("k", "v").SetStatus(SpanStatus.Error); + Assert.That(span.StartChild("child"), Is.SameAs(NoopSpan.Instance)); + Assert.That(calls, Is.Empty); + } + + [Test] + public void Release_still_happens_when_the_native_finish_throws() + { + var (span, handle, calls, _) = Make(); + handle.ThrowOnFinish = true; + Assert.Throws(() => span.Finish()); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); + Assert.That(span.IsFinished, Is.True); + span.Finish(); + Assert.That(calls.Count, Is.EqualTo(2)); + } + + [Test] + public void Finishing_a_parent_cancels_live_children_before_itself_and_releases_all() + { + var (span, _, calls, _) = Make(); + var child = span.StartChild("db"); + var grand = child.StartChild("query"); + var done = span.StartChild("done"); + done.Finish(); + calls.Clear(); + + span.Finish(); + + Assert.That(calls, Is.EqualTo(new[] + { + "query:finish Cancelled", + "query:release", + "db:finish Cancelled", + "db:release", + "root:finish Ok", + "root:release" + })); + Assert.That(child.IsFinished, Is.True); + Assert.That(grand.IsFinished, Is.True); + Assert.That(done.IsFinished, Is.True); + } + + [Test] + public void A_finished_child_is_not_finished_again_by_its_parent() + { + var (span, _, calls, _) = Make(); + var child = span.StartChild("db"); + child.Finish(SpanStatus.Timeout); + calls.Clear(); + span.Finish(); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); + } + + [Test] + public void Child_finish_leaves_the_parent_live() + { + var (span, _, calls, _) = Make(); + var child = span.StartChild("db", "select"); + child.Finish(); + Assert.That(span.IsFinished, Is.False); + Assert.That(calls, Does.Contain("root:child db desc=select")); + Assert.That(calls.FindAll(c => c == "db:release").Count, Is.EqualTo(1)); + } + + [Test] + public void Child_without_description_passes_null_and_an_empty_operation_is_refused() + { + var (span, _, calls, warnings) = Make(); + span.StartChild("db"); + Assert.That(calls, Does.Contain("root:child db desc=")); + Assert.That(span.StartChild(""), Is.SameAs(NoopSpan.Instance)); + Assert.That(span.StartChild(null), Is.SameAs(NoopSpan.Instance)); + Assert.That(warnings.Count, Is.EqualTo(2)); + } + + [Test] + public void A_native_child_that_is_null_yields_an_inert_span() + { + var (span, handle, _, _) = Make(); + handle.ReturnNullChild = true; + Assert.That(span.StartChild("db"), Is.SameAs(NoopSpan.Instance)); + } + + [Test] + public void Setters_validate_and_chain() + { + var (span, _, calls, warnings) = Make(); + Assert.That(span.SetName("n"), Is.SameAs(span)); + span.SetName(""); + span.SetName(null); + span.SetDescription(null); + span.SetAttribute("k", 5L); + span.SetAttribute("big", ulong.MaxValue); + span.SetAttribute("", "x"); + span.SetAttribute("obj", new object()); + span.SetStatus((SpanStatus)99); + Assert.That(calls, Is.EqualTo(new[] + { + "root:name=n", + "root:desc=", + "root:attr k=5:Int64", + "root:status=Unknown" + })); + Assert.That(warnings.Count, Is.EqualTo(5)); + } + + [Test] + public void Attribute_values_are_made_boxable_before_the_handle() + { + var (span, _, calls, _) = Make(); + span.SetAttribute("u", (uint)7); + span.SetAttribute("d", 1.5m); + Assert.That(calls, Is.EqualTo(new[] { "root:attr u=7:Int64", "root:attr d=1.5:Double" })); + } + + [Test] + public void Concurrent_finishes_release_once() + { + var (span, _, calls, _) = Make(); + var tasks = new System.Threading.Tasks.Task[8]; + for (int i = 0; i < tasks.Length; i++) + tasks[i] = System.Threading.Tasks.Task.Run(() => span.Finish()); + System.Threading.Tasks.Task.WaitAll(tasks); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); + } + + [Test] + public void Null_handle_is_rejected() + { + Assert.Throws(() => new ManagedSpan(null, "op")); + } + + [Test] + public void Noop_span_accepts_everything() + { + var noop = NoopSpan.Instance; + Assert.That(noop.SetName("a").SetAttribute("k", 1).StartChild("x"), Is.SameAs(noop)); + noop.Finish(); + noop.Dispose(); + Assert.That(noop.SpanId, Is.Empty); + } + } +} diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 7bf6ab0..73cfece 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -144,6 +144,46 @@ public void Notify_reaches_the_native_five_argument_overloads() Assert.That(native, Does.Contain("urgent:urgent != 0")); } + [Test] + public void Facade_start_transaction_is_inert_before_launch_and_wraps_the_native_handle() + { + string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); + string body = ExtractMethodBody(facade, "public static ISpan StartTransaction("); + Assert.That(body, Does.Contain("SpanPolicy.HasText(name) || !SpanPolicy.HasText(operation)")); + Assert.That(body, Does.Contain("if (!Bridge.GetLaunched()) return NoopSpan.Instance;")); + Assert.That(body, Does.Contain("SpanPolicy.CleanAttributes(attributes, rejected)")); + Assert.That(body, Does.Contain("new ManagedSpan(handle, operation,")); + Assert.That(facade, Does.Not.Contain("GetActiveSpan")); + } + + [Test] + public void Android_spans_hold_one_java_object_and_dispose_it_on_release() + { + string bridge = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + Assert.That(ExtractMethodBody(bridge, "public ISpanHandle StartTransaction("), + Does.Contain("_bugsee.CallStatic(\"startTransaction\", name, operation, javaAttributes)")); + string handle = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidSpanHandle.cs")); + string release = ExtractMethodBody(handle, "public void Release()"); + Assert.That(release, Does.Contain("_span?.Dispose();")); + Assert.That(release, Does.Contain("_span = null;")); + Assert.That(ExtractMethodBody(handle, "public void Finish(SpanStatus status)"), Does.Contain("_span.Call(\"finish\", native)")); + Assert.That(ExtractMethodBody(handle, "public ISpanHandle StartChild("), Does.Contain("\"startChildSpan\", operation)")); + } + + [Test] + public void Ios_spans_are_registry_ids_released_by_release_and_the_finalizer() + { + string handle = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosSpanHandle.cs")); + Assert.That(handle, Does.Contain("~IosSpanHandle() => Release();")); + string release = ExtractMethodBody(handle, "public void Release()"); + Assert.That(release, Does.Contain("Interlocked.Exchange(ref _id, 0)")); + Assert.That(release, Does.Contain("_bugsee_span_release(id)")); + string mm = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + Assert.That(mm, Does.Contain("[registry removeObjectForKey:@(spanId)]")); + Assert.That(mm, Does.Contain("[Bugsee startTransactionWithName:nameStr operation:operationStr attributes:attributes]")); + Assert.That(mm, Does.Not.Contain("getActiveSpan")); + } + [Test] public void Facade_async_lifecycle_uses_the_result_bridge_and_never_blocks() { diff --git a/Tests~/WrapperPolicy/SpanPolicyTests.cs b/Tests~/WrapperPolicy/SpanPolicyTests.cs new file mode 100644 index 0000000..d0b8719 --- /dev/null +++ b/Tests~/WrapperPolicy/SpanPolicyTests.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using Bugsee.Contracts.Apm; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class SpanPolicyTests + { + [Test] + public void Status_names_match_the_android_enum_constants() + { + Assert.That(SpanPolicy.AndroidStatusName(SpanStatus.Ok), Is.EqualTo("OK")); + Assert.That(SpanPolicy.AndroidStatusName(SpanStatus.Error), Is.EqualTo("ERROR")); + Assert.That(SpanPolicy.AndroidStatusName(SpanStatus.Timeout), Is.EqualTo("TIMEOUT")); + Assert.That(SpanPolicy.AndroidStatusName(SpanStatus.Cancelled), Is.EqualTo("CANCELLED")); + Assert.That(SpanPolicy.AndroidStatusName(SpanStatus.DeadlineExceeded), Is.EqualTo("DEADLINE_EXCEEDED")); + Assert.That(SpanPolicy.AndroidStatusName(SpanStatus.Unknown), Is.EqualTo("UNKNOWN")); + Assert.That(SpanPolicy.AndroidStatusName((SpanStatus)42), Is.EqualTo("UNKNOWN")); + } + + [Test] + public void Status_values_are_the_ios_wire_values() + { + Assert.That((int)SpanStatus.Ok, Is.EqualTo(0)); + Assert.That((int)SpanStatus.Error, Is.EqualTo(1)); + Assert.That((int)SpanStatus.Timeout, Is.EqualTo(2)); + Assert.That((int)SpanStatus.Cancelled, Is.EqualTo(3)); + Assert.That((int)SpanStatus.DeadlineExceeded, Is.EqualTo(4)); + Assert.That((int)SpanStatus.Unknown, Is.EqualTo(5)); + Assert.That(SpanPolicy.Normalize((SpanStatus)(-1)), Is.EqualTo(SpanStatus.Unknown)); + } + + [Test] + public void Description_null_crosses_as_empty() + { + Assert.That(SpanPolicy.Description(null), Is.EqualTo("")); + Assert.That(SpanPolicy.Description("x"), Is.EqualTo("x")); + } + + [Test] + public void CleanAttributes_keeps_accepted_entries_and_reports_the_rest() + { + var rejected = new List(); + var clean = SpanPolicy.CleanAttributes(new Dictionary + { + ["s"] = "v", + ["u"] = (uint)3, + ["bad"] = new object(), + ["nan"] = double.NaN + }, rejected); + Assert.That(clean.Count, Is.EqualTo(2)); + Assert.That(clean["u"], Is.EqualTo(3L)); + Assert.That(rejected.Count, Is.EqualTo(2)); + } + + [Test] + public void CleanAttributes_is_null_when_nothing_is_left() + { + Assert.That(SpanPolicy.CleanAttributes(null), Is.Null); + Assert.That(SpanPolicy.CleanAttributes(new Dictionary()), Is.Null); + Assert.That(SpanPolicy.CleanAttributes(new Dictionary { ["x"] = new object() }), Is.Null); + } + } +} diff --git a/Tests~/iOS/BridgeTests.mm b/Tests~/iOS/BridgeTests.mm index bf1147c..7eee1b6 100644 --- a/Tests~/iOS/BridgeTests.mm +++ b/Tests~/iOS/BridgeTests.mm @@ -18,6 +18,16 @@ void _bugsee_clear_attribute(const char *key); char *_bugsee_get_launch_options(void); void _bugsee_notify(const char *title, const char *body, int severity, const char *fieldsJson, int urgent); +int64_t _bugsee_start_transaction(const char *name, const char *operation, const char *attributesJson); +int64_t _bugsee_span_start_child(int64_t spanId, const char *operation, const char *description); +char *_bugsee_span_get_id(int64_t spanId); +char *_bugsee_span_get_trace_id(int64_t spanId); +void _bugsee_span_set_name(int64_t spanId, const char *name); +void _bugsee_span_set_description(int64_t spanId, const char *description); +void _bugsee_span_set_attribute(int64_t spanId, const char *key, const char *valueJson); +void _bugsee_span_set_status(int64_t spanId, int status); +void _bugsee_span_finish(int64_t spanId, int status); +void _bugsee_span_release(int64_t spanId); int _bugsee_appearance_set_color(const char *propertyName, int r, int g, int b, int a); char *_bugsee_appearance_get_color(const char *propertyName); int _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue); @@ -271,6 +281,43 @@ int main(void) _bugsee_notify("title", "body", 3, "{\"env\":\"prod\",\"n\":1}", 1); _bugsee_notify("", "body", 3, NULL, 0); _bugsee_notify(NULL, NULL, 3, "not json", 0); + // APM: spans cross as registry ids; an id is valid until released and every call on a stale id is a no-op. + expect(_bugsee_start_transaction("", "op", NULL) == 0 && _bugsee_start_transaction("n", NULL, NULL) == 0, + "a transaction needs a name and an operation"); + int64_t txn = _bugsee_start_transaction("checkout", "user.flow", "{\"k\":\"v\",\"n\":2}"); + expect(txn > 0, "a transaction gets a registry id"); + char *txnSpanId = _bugsee_span_get_id(txn); + char *txnTraceId = _bugsee_span_get_trace_id(txn); + expect(txnSpanId && strlen(txnSpanId) > 0 && txnTraceId && strlen(txnTraceId) > 0, "a transaction has span and trace ids"); + int64_t child = _bugsee_span_start_child(txn, "db", NULL); + int64_t child2 = _bugsee_span_start_child(txn, "db2", "select"); + expect(child > 0 && child2 > 0 && child != txn && child2 != child, "children get distinct ids"); + expect(_bugsee_span_start_child(txn, "", NULL) == 0, "a child needs an operation"); + char *childTraceId = _bugsee_span_get_trace_id(child); + expect(childTraceId && txnTraceId && strcmp(childTraceId, txnTraceId) == 0, "a child shares the trace"); + _bugsee_span_set_name(txn, "renamed"); + _bugsee_span_set_name(txn, ""); + _bugsee_span_set_description(txn, NULL); + _bugsee_span_set_attribute(txn, "k2", "\"v\""); + _bugsee_span_set_attribute(txn, "k3", "not json"); + _bugsee_span_set_attribute(txn, "k4", "null"); + _bugsee_span_set_status(child, 2); + _bugsee_span_finish(child, 1); + _bugsee_span_release(child); + _bugsee_span_finish(child, 0); + _bugsee_span_set_name(child, "stale"); + expect(_bugsee_span_get_id(child) == NULL, "a released span has no id"); + expect(_bugsee_span_start_child(child, "x", NULL) == 0, "a released span starts no children"); + _bugsee_span_finish(txn, 0); + _bugsee_span_release(child2); + _bugsee_span_release(txn); + _bugsee_span_release(txn); + expect(_bugsee_span_get_id(txn) == NULL, "a released transaction has no id"); + _bugsee_span_finish(0, 0); + _bugsee_span_release(987654); + free(txnSpanId); + free(txnTraceId); + free(childTraceId); _bugsee_stop(7, OnStopped); expect(waitFor(^{ return gStoppedToken == 7 ? 1 : 0; }, 5), "stop reports completion with its token"); _bugsee_relaunch(NULL, 42, OnStarted); From 5c906c9195c17b77bf5780065287ce289e19a3a5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:20:36 +0500 Subject: [PATCH 115/157] Document secure-rectangle coordinate-space limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 4 +++- Runtime/Bugsee.cs | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Documentation~/index.md b/Documentation~/index.md index 8ef8c81..9850aeb 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -221,7 +221,9 @@ static RectInt ToSecureRect(Rect unityScreenRect) } ``` -A `RectTransform` corner from `GetWorldCorners` in a screen-space overlay canvas is also bottom-left: build the `Rect` from those corners, then convert. Rectangles are not flipped on rotation or resize; add them again when the screen size changes. A rectangle is removed with the same top-left rectangle it was added with. +A `RectTransform` corner from `GetWorldCorners` in a screen-space overlay canvas is also bottom-left: build the `Rect` from those corners, then convert. Rectangles are not flipped, scaled or moved on rotation or resize; rectangles are keyed by their exact left/top/right/bottom, so when the screen size changes remove the old rectangle (or call `RemoveAllSecureRectangles()`) before adding the new one. + +**Limitation:** the rectangle must already be in the native display's coordinate space; the wrapper does no scaling or offsetting beyond the iOS points conversion. With Android Resolution Scaling or `Screen.SetResolution`, or a non-fullscreen Unity view (status bar or notch offset), `Screen.height` and pixels differ from native pixels, so scale or offset the rectangle yourself. On iOS the wrapper divides by `UIScreen.scale`, which is wrong if the render size differs from points x scale. A rectangle is removed with the same top-left rectangle it was added with. ## Native dependencies diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index f3e74f9..3300974 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -297,6 +297,11 @@ public static void SetUserIdentifier(string userIdentifier) => /// expect (Android pixels; iOS points, converted by the screen scale). The rectangle is passed through /// as given: it is NOT flipped from Unity's bottom-left Screen/Input space. Convert with /// y = Screen.height - rect.yMax; see the package documentation. + /// Limitation: the rectangle must already be in the native display's coordinate space. With Android + /// Resolution Scaling or Screen.SetResolution, or a non-fullscreen Unity view (status bar or notch + /// offset), Screen.height and pixels differ from native pixels, so scale or offset the rectangle + /// yourself. On iOS the wrapper divides by UIScreen.scale, which is wrong if the render size + /// differs from points x scale. /// public static void AddSecureRectangle(RectInt pixelRect) { From d97ecf1b7aa9ca3b6abc51ff8f4718c6882dba56 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:23:51 +0500 Subject: [PATCH 116/157] Make APM spans never throw, keep typed iOS attributes, drop JNI attach. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each child finish is isolated and the parent is always finished and released once; setters and StartChild catch native failures and warn. iOS transaction attributes use the SetAttribute wire so a double stays a double. AndroidSpanHandle no longer attaches threads. NoopSpan reads as finished after Finish. Docs state the native no-op span has ids "0". Source-grep tests replaced by behaviour tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 2 +- Runtime/Bugsee.cs | 6 +- Runtime/Contracts/Apm/ISpan.cs | 6 +- Runtime/Platform/Android/AndroidSpanHandle.cs | 3 - Runtime/Platform/IOS/IOSBridge.cs | 2 +- Runtime/WrapperPolicy/ManagedSpan.cs | 179 ++++++++++-------- Runtime/WrapperPolicy/SpanPolicy.cs | 14 ++ Tests~/WrapperPolicy/ManagedSpanTests.cs | 96 ++++++++-- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 40 ---- Tests~/WrapperPolicy/SpanPolicyTests.cs | 15 ++ 10 files changed, 219 insertions(+), 144 deletions(-) diff --git a/Documentation~/index.md b/Documentation~/index.md index a89e34a..92bc772 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -239,6 +239,6 @@ using (var pay = checkout.StartChild("payment", "charge card")) `Bugsee.StartTransaction(name, operation, attributes = null)` returns an `ISpan` (`SpanId`, `TraceId`, `Operation`, `IsFinished`, `SetName`, `SetDescription`, `SetAttribute`, `SetStatus`, `StartChild(operation, description = null)`, `Finish()` / `Finish(SpanStatus)`, `Dispose` = `Finish()`). Attribute values follow the `SetAttribute` rules; a rejected value is dropped with a Unity warning (spans never throw). The statuses are `Ok`, `Error`, `Timeout`, `Cancelled`, `DeadlineExceeded` and `Unknown`. -A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids) while the SDK is not launched or when the name or operation is empty; iOS documents a shared no-op span when performance monitoring is off (Android's behaviour is not documented; a null result from it is also treated as inert), so the wrapper never reports a failure. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). Android threading of a span is not documented by the native SDK, so use a span from the thread that created it unless you serialize access yourself. +A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids, reads as finished after `Finish`) while the SDK is not launched or when the name or operation is empty. With performance monitoring off, both native SDKs return their own shared no-op span (ids read `"0"`), which is wrapped like any other span. No call on a span throws: invalid input and native failures log a Unity warning. If a child finishes on another thread while its parent finishes, the status that reached the child first wins. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). Android threading of a span is not documented by the native SDK, so use a span from the thread that created it unless you serialize access yourself. Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index af2d950..54b484e 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -290,9 +290,9 @@ public static ISpan StartTransaction(string name, string operation, IDictionary< if (!SpanPolicy.HasText(name) || !SpanPolicy.HasText(operation)) { Debug.LogWarning("[Bugsee] StartTransaction ignored: a name and an operation are required."); - return NoopSpan.Instance; + return new NoopSpan(); } - if (!Bridge.GetLaunched()) return NoopSpan.Instance; + if (!Bridge.GetLaunched()) return new NoopSpan(); var rejected = new List(); var clean = SpanPolicy.CleanAttributes(attributes, rejected); @@ -300,7 +300,7 @@ public static ISpan StartTransaction(string name, string operation, IDictionary< var handle = Bridge.StartTransaction(name, operation, clean); return handle == null - ? NoopSpan.Instance + ? new NoopSpan() : new ManagedSpan(handle, operation, message => Debug.LogWarning("[Bugsee] " + message)); } diff --git a/Runtime/Contracts/Apm/ISpan.cs b/Runtime/Contracts/Apm/ISpan.cs index 43819a2..eba328b 100644 --- a/Runtime/Contracts/Apm/ISpan.cs +++ b/Runtime/Contracts/Apm/ISpan.cs @@ -7,12 +7,12 @@ namespace Bugsee.Contracts.Apm /// It wraps a native span: finish it (or dispose it) to release the native object. Finishing a span first /// finishes its still-live children as . Everything is safe to call after /// (it does nothing), and a span started while the SDK is not launched or without a - /// valid name is an inert span that accepts every call. Setters that take invalid input log a warning and - /// leave the span unchanged rather than throw. + /// valid name is an inert span (empty ids) that accepts every call. Nothing here throws: invalid input and + /// native failures log a warning and leave the span unchanged. /// public interface ISpan : IDisposable { - /// The native span id, or an empty string for an inert span. + /// The native span id ("0" for the native no-op span used when performance monitoring is off), or an empty string for an inert span. string SpanId { get; } /// The id of the trace the span belongs to, or an empty string for an inert span. diff --git a/Runtime/Platform/Android/AndroidSpanHandle.cs b/Runtime/Platform/Android/AndroidSpanHandle.cs index 1b9b439..bd4de18 100644 --- a/Runtime/Platform/Android/AndroidSpanHandle.cs +++ b/Runtime/Platform/Android/AndroidSpanHandle.cs @@ -52,7 +52,6 @@ public void SetStatus(SpanStatus status) public ISpanHandle StartChild(string operation, string description) { - AndroidJNI.AttachCurrentThread(); // A null cannot select a JNI overload, so no description uses the one-argument method. var child = description == null ? _span.Call("startChildSpan", operation) @@ -62,7 +61,6 @@ public ISpanHandle StartChild(string operation, string description) public void Finish(SpanStatus status) { - AndroidJNI.AttachCurrentThread(); using (var native = NativeStatus(status)) _span.Call("finish", native); } @@ -76,7 +74,6 @@ public void Release() // The setters return the span for chaining; each call returns a new reference that must be disposed. void Chain(string method, params object[] args) { - AndroidJNI.AttachCurrentThread(); using (_span.Call(method, args)) { } } diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index 569509c..6f2125f 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -667,7 +667,7 @@ public void Notify(string title, string body, IssueSeverity severity, IDictionar public ISpanHandle StartTransaction(string name, string operation, IDictionary attributes) { - long id = IosSpanHandle._bugsee_start_transaction(name, operation, ToJsonObject(attributes)); + long id = IosSpanHandle._bugsee_start_transaction(name, operation, SpanPolicy.AttributesToJson(attributes)); return id == 0 ? null : new IosSpanHandle(id); } diff --git a/Runtime/WrapperPolicy/ManagedSpan.cs b/Runtime/WrapperPolicy/ManagedSpan.cs index f322f1c..94994e2 100644 --- a/Runtime/WrapperPolicy/ManagedSpan.cs +++ b/Runtime/WrapperPolicy/ManagedSpan.cs @@ -7,9 +7,11 @@ namespace Bugsee.WrapperPolicy /// /// The managed span: all lifetime and argument rules around one . A span is finished /// once; that finishes its live children as Cancelled first (so the result does not depend on whether the native - /// SDK cascades), calls the native finish, then releases the handle. After that no native call is made. Both - /// the finish and the release run even if a native call throws. Thread-safe: the state is guarded and the - /// handle is only used while the span is live. + /// SDK cascades), calls the native finish, then releases the handle. After that no native call is made. A native + /// call that throws is reported through the warning callback and never escapes: every child is finished + /// independently, the parent is always finished, and each handle is released once. A child finishing on another + /// thread while its parent finishes keeps whichever status reached it first (each span's state is flipped under + /// its own lock). Thread-safe: the state is guarded and the handle is only used while the span is live. /// public sealed class ManagedSpan : ISpan { @@ -52,21 +54,11 @@ public ISpan SetName(string name) Warn("SetName ignored: a name is required."); return this; } - lock (_gate) - { - if (!_finished) _handle.SetName(name); - } - return this; + return Guarded("SetName", h => h.SetName(name)); } - public ISpan SetDescription(string description) - { - lock (_gate) - { - if (!_finished) _handle.SetDescription(SpanPolicy.Description(description)); - } - return this; - } + public ISpan SetDescription(string description) => + Guarded("SetDescription", h => h.SetDescription(SpanPolicy.Description(description))); public ISpan SetAttribute(string key, object value) { @@ -76,37 +68,35 @@ public ISpan SetAttribute(string key, object value) Warn("SetAttribute ignored: " + decision.Error + "."); return this; } - lock (_gate) - { - if (!_finished) _handle.SetAttribute(key, AttributeWire.ToBoxable(value)); - } - return this; + return Guarded("SetAttribute", h => h.SetAttribute(key, AttributeWire.ToBoxable(value))); } - public ISpan SetStatus(SpanStatus status) - { - lock (_gate) - { - if (!_finished) _handle.SetStatus(SpanPolicy.Normalize(status)); - } - return this; - } + public ISpan SetStatus(SpanStatus status) => + Guarded("SetStatus", h => h.SetStatus(SpanPolicy.Normalize(status))); public ISpan StartChild(string operation, string description = null) { if (!SpanPolicy.HasText(operation)) { Warn("StartChild ignored: an operation is required."); - return NoopSpan.Instance; + return new NoopSpan(); } lock (_gate) { - if (_finished) return NoopSpan.Instance; - var childHandle = _handle.StartChild(operation, description); - if (childHandle == null) return NoopSpan.Instance; - var child = new ManagedSpan(childHandle, operation, _warn, this); - _children.Add(child); - return child; + if (_finished) return new NoopSpan(); + try + { + var childHandle = _handle.StartChild(operation, description); + if (childHandle == null) return new NoopSpan(); + var child = new ManagedSpan(childHandle, operation, _warn, this); + _children.Add(child); + return child; + } + catch (Exception e) + { + Warn("StartChild failed: " + e.Message); + return new NoopSpan(); + } } } @@ -114,29 +104,12 @@ public ISpan StartChild(string operation, string description = null) public void Finish(SpanStatus status) { - ManagedSpan[] children; - ISpanHandle handle; - lock (_gate) - { - if (_finished) return; - _finished = true; - handle = _handle; - _handle = null; - children = _children.ToArray(); - _children.Clear(); - } + var handle = Detach(out var children); + if (handle == null) return; - try - { - // A child's own finish removes it from this list; the snapshot is already detached. - foreach (var child in children) child.FinishFromParent(); - handle.Finish(SpanPolicy.Normalize(status)); - } - finally - { - handle.Release(); - _parent?.Forget(this); - } + foreach (var child in children) child.FinishFromParent(); + FinishAndRelease(handle, SpanPolicy.Normalize(status)); + _parent?.Forget(this); } public void Dispose() => Finish(); @@ -144,34 +117,67 @@ public void Finish(SpanStatus status) void FinishFromParent() { // The parent already detached this child, so it must not call back into the parent's list. - ISpanHandle handle; + var handle = Detach(out var grandChildren); + if (handle == null) return; + + foreach (var child in grandChildren) child.FinishFromParent(); + FinishAndRelease(handle, SpanStatus.Cancelled); + } + + // Flips the span to finished and hands back its handle and live children, or null when it was already finished. + ISpanHandle Detach(out ManagedSpan[] children) + { lock (_gate) { - if (_finished) return; + if (_finished) + { + children = null; + return null; + } _finished = true; - handle = _handle; + var handle = _handle; _handle = null; + children = _children.ToArray(); + _children.Clear(); + return handle; } - var grandChildren = DetachChildren(); + } + + void FinishAndRelease(ISpanHandle handle, SpanStatus status) + { try { - foreach (var child in grandChildren) child.FinishFromParent(); - handle.Finish(SpanStatus.Cancelled); + handle.Finish(status); } - finally + catch (Exception e) + { + Warn("Finish failed: " + e.Message); + } + try { handle.Release(); } + catch (Exception e) + { + Warn("Release failed: " + e.Message); + } } - ManagedSpan[] DetachChildren() + ISpan Guarded(string what, Action call) { lock (_gate) { - var children = _children.ToArray(); - _children.Clear(); - return children; + if (_finished) return this; + try + { + call(_handle); + } + catch (Exception e) + { + Warn(what + " failed: " + e.Message); + } } + return this; } void Forget(ManagedSpan child) @@ -179,29 +185,40 @@ void Forget(ManagedSpan child) lock (_gate) _children.Remove(child); } - void Warn(string message) => _warn?.Invoke(message); + void Warn(string message) + { + try + { + _warn?.Invoke(message); + } + catch + { + // A logging callback must not break span handling. + } + } } - /// The span returned when there is nothing native behind it: every call is accepted and does nothing. + /// + /// The span returned when there is nothing behind it (the SDK is not launched, an argument was empty, the native + /// SDK produced none): every call is accepted and does nothing, the ids are empty, and it reads as finished + /// once or was called. (When performance monitoring is off the + /// native SDKs return their own no-op span, whose ids read "0"; that one is wrapped like any other span.) + /// public sealed class NoopSpan : ISpan { - public static readonly NoopSpan Instance = new NoopSpan(); - - NoopSpan() - { - } + volatile bool _finished; public string SpanId => ""; public string TraceId => ""; public string Operation => ""; - public bool IsFinished => false; + public bool IsFinished => _finished; public ISpan SetName(string name) => this; public ISpan SetDescription(string description) => this; public ISpan SetAttribute(string key, object value) => this; public ISpan SetStatus(SpanStatus status) => this; - public ISpan StartChild(string operation, string description = null) => this; - public void Finish() { } - public void Finish(SpanStatus status) { } - public void Dispose() { } + public ISpan StartChild(string operation, string description = null) => new NoopSpan(); + public void Finish() => _finished = true; + public void Finish(SpanStatus status) => _finished = true; + public void Dispose() => _finished = true; } } diff --git a/Runtime/WrapperPolicy/SpanPolicy.cs b/Runtime/WrapperPolicy/SpanPolicy.cs index 441ebba..4204410 100644 --- a/Runtime/WrapperPolicy/SpanPolicy.cs +++ b/Runtime/WrapperPolicy/SpanPolicy.cs @@ -50,5 +50,19 @@ public static Dictionary CleanAttributes(IDictionary + /// The cleaned attributes as a JSON object for iOS, each value written the way SetAttribute writes it + /// (, so a floating 1.0 stays 1.0 and is not stored as an integer), or null + /// when there are none. + /// + public static string AttributesToJson(IDictionary attributes) + { + if (attributes == null || attributes.Count == 0) return null; + var parts = new List(attributes.Count); + foreach (var kv in attributes) + parts.Add(JsonWire.String(kv.Key) + ":" + AttributeWire.ToJson(kv.Value)); + return "{" + string.Join(",", parts.ToArray()) + "}"; + } } } diff --git a/Tests~/WrapperPolicy/ManagedSpanTests.cs b/Tests~/WrapperPolicy/ManagedSpanTests.cs index 76e9a0e..431cf3e 100644 --- a/Tests~/WrapperPolicy/ManagedSpanTests.cs +++ b/Tests~/WrapperPolicy/ManagedSpanTests.cs @@ -13,6 +13,8 @@ sealed class FakeHandle : ISpanHandle public readonly List Children = new List(); public bool ThrowOnFinish; public bool ReturnNullChild; + public bool ThrowOnSetName; + public bool ThrowOnRelease; readonly string _name; public FakeHandle(string name, List calls) @@ -23,7 +25,11 @@ public FakeHandle(string name, List calls) public string SpanId => "span-" + _name; public string TraceId => "trace"; - public void SetName(string name) => Calls.Add(_name + ":name=" + name); + public void SetName(string name) + { + if (ThrowOnSetName) throw new InvalidOperationException("setter failed"); + Calls.Add(_name + ":name=" + name); + } public void SetDescription(string description) => Calls.Add(_name + ":desc=" + description); public void SetAttribute(string key, object value) => Calls.Add(_name + ":attr " + key + "=" + string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}", value) + ":" + value.GetType().Name); public void SetStatus(SpanStatus status) => Calls.Add(_name + ":status=" + status); @@ -43,7 +49,11 @@ public void Finish(SpanStatus status) if (ThrowOnFinish) throw new InvalidOperationException("native finish failed"); } - public void Release() => Calls.Add(_name + ":release"); + public void Release() + { + Calls.Add(_name + ":release"); + if (ThrowOnRelease) throw new InvalidOperationException("release failed"); + } } static (ManagedSpan span, FakeHandle handle, List calls, List warnings) Make() @@ -90,20 +100,79 @@ public void Calls_after_finish_do_not_reach_native() span.Finish(); calls.Clear(); span.SetName("n").SetDescription("d").SetAttribute("k", "v").SetStatus(SpanStatus.Error); - Assert.That(span.StartChild("child"), Is.SameAs(NoopSpan.Instance)); + Assert.That(span.StartChild("child"), Is.InstanceOf()); Assert.That(calls, Is.Empty); } [Test] - public void Release_still_happens_when_the_native_finish_throws() + public void A_throwing_parent_finish_is_swallowed_and_the_handle_is_still_released_once() { - var (span, handle, calls, _) = Make(); + var (span, handle, calls, warnings) = Make(); handle.ThrowOnFinish = true; - Assert.Throws(() => span.Finish()); + Assert.DoesNotThrow(() => span.Finish()); Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); Assert.That(span.IsFinished, Is.True); - span.Finish(); + span.Dispose(); Assert.That(calls.Count, Is.EqualTo(2)); + Assert.That(warnings.Count, Is.EqualTo(1)); + } + + [Test] + public void A_throwing_child_finish_does_not_stop_siblings_or_the_parent() + { + var (span, handle, calls, warnings) = Make(); + span.StartChild("a"); + span.StartChild("b"); + handle.Children[0].ThrowOnFinish = true; + calls.Clear(); + Assert.DoesNotThrow(() => span.Dispose()); + Assert.That(calls, Is.EqualTo(new[] + { + "a:finish Cancelled", "a:release", + "b:finish Cancelled", "b:release", + "root:finish Ok", "root:release" + })); + Assert.That(warnings.Count, Is.EqualTo(1)); + } + + [Test] + public void A_throwing_release_is_swallowed() + { + var (span, handle, _, warnings) = Make(); + handle.ThrowOnRelease = true; + Assert.DoesNotThrow(() => span.Finish()); + Assert.That(warnings.Count, Is.EqualTo(1)); + } + + [Test] + public void A_throwing_setter_warns_and_leaves_the_span_live() + { + var (span, handle, calls, warnings) = Make(); + handle.ThrowOnSetName = true; + Assert.That(span.SetName("x"), Is.SameAs(span)); + Assert.That(warnings.Count, Is.EqualTo(1)); + Assert.That(span.IsFinished, Is.False); + span.Finish(); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); + } + + [Test] + public void A_throwing_warning_callback_does_not_escape() + { + var handle = new FakeHandle("root", new List()) { ThrowOnFinish = true }; + var span = new ManagedSpan(handle, "op", _ => throw new InvalidOperationException("log")); + Assert.DoesNotThrow(() => span.Finish()); + } + + [Test] + public void A_child_that_finished_first_keeps_its_own_status_when_the_parent_finishes() + { + var (span, _, calls, _) = Make(); + var child = span.StartChild("db"); + child.Finish(SpanStatus.Timeout); + span.Finish(); + Assert.That(calls, Does.Contain("db:finish Timeout")); + Assert.That(calls, Does.Not.Contain("db:finish Cancelled")); } [Test] @@ -160,8 +229,8 @@ public void Child_without_description_passes_null_and_an_empty_operation_is_refu var (span, _, calls, warnings) = Make(); span.StartChild("db"); Assert.That(calls, Does.Contain("root:child db desc=")); - Assert.That(span.StartChild(""), Is.SameAs(NoopSpan.Instance)); - Assert.That(span.StartChild(null), Is.SameAs(NoopSpan.Instance)); + Assert.That(span.StartChild(""), Is.InstanceOf()); + Assert.That(span.StartChild(null), Is.InstanceOf()); Assert.That(warnings.Count, Is.EqualTo(2)); } @@ -170,7 +239,7 @@ public void A_native_child_that_is_null_yields_an_inert_span() { var (span, handle, _, _) = Make(); handle.ReturnNullChild = true; - Assert.That(span.StartChild("db"), Is.SameAs(NoopSpan.Instance)); + Assert.That(span.StartChild("db"), Is.InstanceOf()); } [Test] @@ -225,9 +294,12 @@ public void Null_handle_is_rejected() [Test] public void Noop_span_accepts_everything() { - var noop = NoopSpan.Instance; - Assert.That(noop.SetName("a").SetAttribute("k", 1).StartChild("x"), Is.SameAs(noop)); + var noop = new NoopSpan(); + Assert.That(noop.SetName("a").SetAttribute("k", 1), Is.SameAs(noop)); + Assert.That(noop.StartChild("x"), Is.InstanceOf()); + Assert.That(noop.IsFinished, Is.False); noop.Finish(); + Assert.That(noop.IsFinished, Is.True); noop.Dispose(); Assert.That(noop.SpanId, Is.Empty); } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 73cfece..7bf6ab0 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -144,46 +144,6 @@ public void Notify_reaches_the_native_five_argument_overloads() Assert.That(native, Does.Contain("urgent:urgent != 0")); } - [Test] - public void Facade_start_transaction_is_inert_before_launch_and_wraps_the_native_handle() - { - string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); - string body = ExtractMethodBody(facade, "public static ISpan StartTransaction("); - Assert.That(body, Does.Contain("SpanPolicy.HasText(name) || !SpanPolicy.HasText(operation)")); - Assert.That(body, Does.Contain("if (!Bridge.GetLaunched()) return NoopSpan.Instance;")); - Assert.That(body, Does.Contain("SpanPolicy.CleanAttributes(attributes, rejected)")); - Assert.That(body, Does.Contain("new ManagedSpan(handle, operation,")); - Assert.That(facade, Does.Not.Contain("GetActiveSpan")); - } - - [Test] - public void Android_spans_hold_one_java_object_and_dispose_it_on_release() - { - string bridge = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); - Assert.That(ExtractMethodBody(bridge, "public ISpanHandle StartTransaction("), - Does.Contain("_bugsee.CallStatic(\"startTransaction\", name, operation, javaAttributes)")); - string handle = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidSpanHandle.cs")); - string release = ExtractMethodBody(handle, "public void Release()"); - Assert.That(release, Does.Contain("_span?.Dispose();")); - Assert.That(release, Does.Contain("_span = null;")); - Assert.That(ExtractMethodBody(handle, "public void Finish(SpanStatus status)"), Does.Contain("_span.Call(\"finish\", native)")); - Assert.That(ExtractMethodBody(handle, "public ISpanHandle StartChild("), Does.Contain("\"startChildSpan\", operation)")); - } - - [Test] - public void Ios_spans_are_registry_ids_released_by_release_and_the_finalizer() - { - string handle = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosSpanHandle.cs")); - Assert.That(handle, Does.Contain("~IosSpanHandle() => Release();")); - string release = ExtractMethodBody(handle, "public void Release()"); - Assert.That(release, Does.Contain("Interlocked.Exchange(ref _id, 0)")); - Assert.That(release, Does.Contain("_bugsee_span_release(id)")); - string mm = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); - Assert.That(mm, Does.Contain("[registry removeObjectForKey:@(spanId)]")); - Assert.That(mm, Does.Contain("[Bugsee startTransactionWithName:nameStr operation:operationStr attributes:attributes]")); - Assert.That(mm, Does.Not.Contain("getActiveSpan")); - } - [Test] public void Facade_async_lifecycle_uses_the_result_bridge_and_never_blocks() { diff --git a/Tests~/WrapperPolicy/SpanPolicyTests.cs b/Tests~/WrapperPolicy/SpanPolicyTests.cs index d0b8719..0b42a8a 100644 --- a/Tests~/WrapperPolicy/SpanPolicyTests.cs +++ b/Tests~/WrapperPolicy/SpanPolicyTests.cs @@ -60,5 +60,20 @@ public void CleanAttributes_is_null_when_nothing_is_left() Assert.That(SpanPolicy.CleanAttributes(new Dictionary()), Is.Null); Assert.That(SpanPolicy.CleanAttributes(new Dictionary { ["x"] = new object() }), Is.Null); } + + [Test] + public void Ios_attribute_json_keeps_a_double_a_double_and_a_bool_a_bool() + { + string json = SpanPolicy.AttributesToJson(new Dictionary + { + ["d"] = 1.0, + ["i"] = 2L, + ["b"] = true, + ["s"] = "x\"y" + }); + Assert.That(json, Is.EqualTo("{\"d\":1.0,\"i\":2,\"b\":true,\"s\":\"x\\\"y\"}")); + Assert.That(SpanPolicy.AttributesToJson(null), Is.Null); + Assert.That(SpanPolicy.AttributesToJson(new Dictionary()), Is.Null); + } } } From 47ea7318a879d2f0175f506456fe2497b0470f10 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:40:32 +0500 Subject: [PATCH 117/157] Verify the Gradle injector against a real Unity 6000.0.84f1 Android export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the generated settings/root/launcher/unityLibrary Gradle files as fixtures and tests the injector over them. Unity 2022 LTS could not be installed via the Hub CLI, so it remains unverified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../Bugsee.WrapperPolicy.Tests.csproj | 3 + .../Unity6000.0.84f1/launcher.build.gradle | 76 +++++++ .../Unity6000.0.84f1/root.build.gradle | 14 ++ .../Fixtures/Unity6000.0.84f1/settings.gradle | 23 +++ .../unityLibrary.build.gradle | 191 ++++++++++++++++++ .../UnityGeneratedLayoutTests.cs | 48 +++++ 6 files changed, 355 insertions(+) create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/launcher.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/root.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/settings.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/unityLibrary.build.gradle create mode 100644 Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 08cf8b6..5bf524b 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -25,4 +25,7 @@ + + + diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/launcher.build.gradle new file mode 100644 index 0000000..a029c1d --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/launcher.build.gradle @@ -0,0 +1,76 @@ +apply plugin: 'com.android.application' +apply from: 'setupSymbols.gradle' +apply from: '../shared/keepUnitySymbols.gradle' + +dependencies { + implementation project(':unityLibrary') +} + +android { + ndkVersion "27.2.12479018" + namespace "com.bugsee.minigame" + ndkPath "/Applications/Unity/Hub/Editor/6000.0.84f1/PlaybackEngines/AndroidPlayer/NDK" + compileSdkVersion 36 + buildToolsVersion '36.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 23 + targetSdkVersion 36 + applicationId 'com.bugsee.minigame' + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + signingConfig signingConfigs.debug + jniDebuggable true + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + signingConfig signingConfigs.debug + ndk { + debugSymbolLevel 'FULL' + } + } + } + + packaging { + jniLibs { + useLegacyPackaging true + } + } + + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +} + diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/root.build.gradle new file mode 100644 index 0000000..9eded7d --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/root.build.gradle @@ -0,0 +1,14 @@ +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 + +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/settings.gradle new file mode 100644 index 0000000..1e121f1 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +include ':launcher' +include ':unityLibrary' + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + + repositories { + google() + mavenCentral() + + flatDir { + dirs "${project(':unityLibrary').projectDir}/libs" + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/unityLibrary.build.gradle new file mode 100644 index 0000000..0be2632 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/unityLibrary.build.gradle @@ -0,0 +1,191 @@ +apply plugin: 'com.android.library' +apply from: '../shared/keepUnitySymbols.gradle' + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'androidx.games:games-frame-pacing:1.10.0' +} + +android { + namespace "com.unity3d.player" + ndkPath "/Applications/Unity/Hub/Editor/6000.0.84f1/PlaybackEngines/AndroidPlayer/NDK" + ndkVersion "27.2.12479018" + compileSdk 36 + buildToolsVersion "36.0.0" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + defaultConfig { + consumerProguardFiles "proguard-unity.txt" + minSdk 23 + targetSdk 36 + + ndk { + abiFilters "arm64-v8a" + debugSymbolLevel "none" + } + + externalNativeBuild { + cmake { + arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + } + } + } + + lint { + abortOnError false + } + + androidResources { + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + } + + packaging { + jniLibs { + useLegacyPackaging true + } + } +} + + +android.externalNativeBuild { + cmake { + version "3.22.1" + path "src/main/cpp/CMakeLists.txt" + } +} +android.buildFeatures { + prefab true +} + +android { + tasks.register('buildIl2Cpp') { + def workingDir = projectDir.toString().replaceAll('\\\\', '/'); + def archs = [ + 'arm64' : 'arm64-v8a' + ] + def staticLibs = [ + 'arm64' : [ ] + ] + inputs.files fileTree(dir: 'src/main/Il2CppOutputProject', include: ['**/*']) + inputs.files fileTree(dir: 'src/main/jniStaticLibs', include: ['**/*']) + archs.each { arch, abi -> + outputs.file getIl2CppOutputPath(workingDir, abi) + outputs.file getIl2CppSymbolPath(workingDir, abi) + } + doLast { + archs.each { arch, abi -> + buildIl2CppImpl(workingDir, 'Release', arch, abi, staticLibs[arch] as String[]); + } + } + } + + sourceSets { + main { + jni.srcDirs = ["src/main/Il2CppOutputProject"] + } + } +} + +androidComponents { + onVariants(selector().all()) { variant -> + tasks.configureEach { task -> + def mergeTaskName = "merge${variant.name.capitalize()}JniLibFolders"; + if (task.name == mergeTaskName) { + dependsOn buildIl2Cpp + } + } + } +} + +def getSdkDir() { + Properties local = new Properties() + local.load(new FileInputStream("${rootDir}/local.properties")) + return local.getProperty('sdk.dir') +} + +def getIl2CppOutputPath(String workingDir, String abi) { + return "${workingDir}/src/main/jniLibs/${abi}/libil2cpp.so"; +} + +def getIl2CppSymbolPath(String workingDir, String abi) { + return "${workingDir}/symbols/${abi}/libil2cpp.so"; +} + +def buildIl2CppImpl(String workingDir, String configuration, String architecture, String abi, String[] staticLibraries) { + def commandLineArgs = [] + commandLineArgs.add("--compile-cpp") + commandLineArgs.add("--platform=Android") + commandLineArgs.add("--architecture=${architecture}") + commandLineArgs.add("--outputpath=${workingDir}/src/main/jniLibs/${abi}/libil2cpp.so") + commandLineArgs.add("--baselib-directory=${workingDir}/src/main/jniStaticLibs/${abi}") + commandLineArgs.add("--incremental-g-c-time-slice=3") + commandLineArgs.add("--configuration=${configuration}") + commandLineArgs.add("--dotnetprofile=unityaot-linux") + commandLineArgs.add("--usymtool-path=${workingDir}/src/main/Il2CppOutputProject/usymtool") + commandLineArgs.add("--profiler-report") + commandLineArgs.add("--profiler-output-file=${workingDir}/build/il2cpp_${abi}_${configuration}/il2cpp_conv.traceevents") + commandLineArgs.add("--print-command-line") + commandLineArgs.add("--static-lib-il2-cpp") + commandLineArgs.add("--data-folder=${workingDir}/src/main/Il2CppOutputProject/Source/il2cppOutput/data") + commandLineArgs.add("--generatedcppdir=${workingDir}/src/main/Il2CppOutputProject/Source/il2cppOutput") + commandLineArgs.add("--cachedirectory=${workingDir}/build/il2cpp_${abi}_${configuration}/il2cpp_cache") + commandLineArgs.add("--tool-chain-path=" + getProperty("unity.androidNdkPath")) + + staticLibraries.each { fileName -> + commandLineArgs.add("--additional-libraries=${workingDir}/src/main/jniStaticLibs/${abi}/${fileName}") + } + + def executableExtension = "" + if (org.gradle.internal.os.OperatingSystem.current().isWindows()) { + executableExtension = ".exe" + commandLineArgs = commandLineArgs*.replace('\"', '\\\"') + } + + def command = "${workingDir}/src/main/Il2CppOutputProject/IL2CPP/build/deploy/il2cpp${executableExtension}"; + execCommand(workingDir, [command, *commandLineArgs], [ + "ANDROID_SDK_ROOT": getProperty("unity.androidSdkPath"), + "ANDROID_NDK_ROOT": getProperty("unity.androidNdkPath"), + "NDK_ROOT": getProperty("unity.androidNdkPath"), + "ANDROID_NDK_HOME": getProperty("unity.androidNdkPath"), + ]) + + def dbgLevel = project.android.defaultConfig.ndk.debugSymbolLevel; + def usePublicSymbols = dbgLevel == null || !dbgLevel.toString().toLowerCase().equals("full") + def extensionToRemove = usePublicSymbols ? ".dbg.so" : ".sym.so" + def extensionToKeep = usePublicSymbols ? ".sym.so" : ".dbg.so" + + delete "${workingDir}/src/main/jniLibs/${abi}/libil2cpp${extensionToRemove}" + ant.move(file: "${workingDir}/src/main/jniLibs/${abi}/libil2cpp${extensionToKeep}", tofile: "${workingDir}/symbols/${abi}/libil2cpp.so") + +} + +def execCommand(String workDir, List command, Map env = [:]) { + def execOutput = providers.exec { + workingDir workDir + commandLine command + environment env + ignoreExitValue true + } + + def exitValue = execOutput.result.get().getExitValue() + def stdout = execOutput.standardOutput.asText.get() + def stderr = execOutput.standardError.asText.get() + + println "$stdout\n$stderr" + if (exitValue != 0) { + def error = new StringBuilder() + error.append("Command execution failed with exit code: $exitValue\n"); + error.append("Working Directory: ${workDir}\n"); + error.append("Environment Variables:\n"); + env.each { key, value -> + error.append(" - ${key} = ${value}\n") + } + error.append("Command Line:\n${command.join(' ')}\n"); + throw new GradleException(error.toString()) + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs b/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs new file mode 100644 index 0000000..536af29 --- /dev/null +++ b/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs @@ -0,0 +1,48 @@ +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The injector over Gradle files Unity actually generated. Fixtures/Unity6000.0.84f1 is an exported (not built) + /// Android Gradle project of the sample from the real Unity 6000.0.84f1 editor with default Gradle templates, + /// minus Bugsee's own edits. No Unity 2022 LTS export is checked in: that editor could not be installed (#48). + /// + public class UnityGeneratedLayoutTests + { + const string Dir = "Fixtures/Unity6000.0.84f1/"; + static readonly string[] Artifacts = { "bugsee-android", "bugsee-android-ndk" }; + + static string Read(string name) + { + return File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, Dir, name)); + } + + [Test] + public void Library_dependencies_land_in_the_top_level_block_and_nothing_else_changes() + { + string raw = Read("unityLibrary.build.gradle"); + var result = GradleMavenInjection.ApplyDependencies(raw, "com.bugsee", Artifacts, "7.4.1"); + var again = GradleMavenInjection.ApplyDependencies(result.Text, "com.bugsee", Artifacts, "7.4.1"); + + Assert.That(result.Text, Does.Contain(" implementation 'com.bugsee:bugsee-android:7.4.1'")); + Assert.That(result.Text, Does.Contain(" implementation 'com.bugsee:bugsee-android-ndk:7.4.1'")); + Assert.That(again.Text, Is.EqualTo(result.Text)); + int begin = result.Text.IndexOf("bugsee:maven-dependencies begin", System.StringComparison.Ordinal); + Assert.That(result.Text.LastIndexOf("dependencies {", begin, System.StringComparison.Ordinal), Is.GreaterThanOrEqualTo(0)); + Assert.That(result.Text.Substring(0, begin).Contains("android {"), Is.False, "before the android block"); + } + + [Test] + public void Settings_already_resolve_from_maven_central_so_nothing_is_added() + { + string settings = Read("settings.gradle"); + + Assert.That(GradleMavenInjection.GetRepositoriesMode(settings), + Is.EqualTo(GradleMavenInjection.RepositoriesMode.PreferSettings)); + var status = GradleMavenInjection.EnsureMavenCentral(settings, "dependencyResolutionManagement", out string updated); + Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(updated, Is.EqualTo(settings)); + } + } +} From 4378500671b806e54cd1ecceff6b872e5b14e51e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:41:08 +0500 Subject: [PATCH 118/157] Guard StartTransaction against native failures and free iOS handles on failed construction. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Bugsee.cs | 8 +-- Runtime/Platform/IOS/IosSpanHandle.cs | 11 +++- Runtime/WrapperPolicy/SpanStart.cs | 48 ++++++++++++++++ Runtime/WrapperPolicy/SpanStart.cs.meta | 11 ++++ Tests~/WrapperPolicy/SpanStartTests.cs | 75 +++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 Runtime/WrapperPolicy/SpanStart.cs create mode 100644 Runtime/WrapperPolicy/SpanStart.cs.meta create mode 100644 Tests~/WrapperPolicy/SpanStartTests.cs diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 54b484e..a742f90 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -298,10 +298,10 @@ public static ISpan StartTransaction(string name, string operation, IDictionary< var clean = SpanPolicy.CleanAttributes(attributes, rejected); foreach (var reason in rejected) Debug.LogWarning("[Bugsee] StartTransaction attribute dropped: " + reason + "."); - var handle = Bridge.StartTransaction(name, operation, clean); - return handle == null - ? new NoopSpan() - : new ManagedSpan(handle, operation, message => Debug.LogWarning("[Bugsee] " + message)); + return SpanStart.Transaction( + () => Bridge.StartTransaction(name, operation, clean), + operation, + message => Debug.LogWarning("[Bugsee] " + message)); } public static void SetUserIdentifier(string userIdentifier) => diff --git a/Runtime/Platform/IOS/IosSpanHandle.cs b/Runtime/Platform/IOS/IosSpanHandle.cs index 9fe553a..5ce7c5b 100644 --- a/Runtime/Platform/IOS/IosSpanHandle.cs +++ b/Runtime/Platform/IOS/IosSpanHandle.cs @@ -18,8 +18,15 @@ sealed class IosSpanHandle : ISpanHandle public IosSpanHandle(long id) { _id = id; - SpanId = Read(_bugsee_span_get_id(id)); - TraceId = Read(_bugsee_span_get_trace_id(id)); + // A constructor that throws never reaches the caller's Release, so it frees the registry entry itself. + SpanStart.ReadIds( + () => Read(_bugsee_span_get_id(id)), + () => Read(_bugsee_span_get_trace_id(id)), + Release, + out var spanId, + out var traceId); + SpanId = spanId; + TraceId = traceId; } ~IosSpanHandle() => Release(); diff --git a/Runtime/WrapperPolicy/SpanStart.cs b/Runtime/WrapperPolicy/SpanStart.cs new file mode 100644 index 0000000..16d47cd --- /dev/null +++ b/Runtime/WrapperPolicy/SpanStart.cs @@ -0,0 +1,48 @@ +using System; +using Bugsee.Contracts.Apm; + +namespace Bugsee.WrapperPolicy +{ + /// Failure-safe construction of spans, so a native failure never escapes and never leaks a handle. + public static class SpanStart + { + /// + /// Starts a transaction through and wraps the handle. Any failure (the native call, or + /// building the wrapper, in which case the handle is released) is reported through and + /// yields an inert span. + /// + public static ISpan Transaction(Func start, string operation, Action warn) + { + ISpanHandle handle = null; + try + { + handle = start(); + return handle == null ? new NoopSpan() : new ManagedSpan(handle, operation, warn); + } + catch (Exception e) + { + try { handle?.Release(); } catch { } + try { warn?.Invoke("StartTransaction failed: " + e.Message); } catch { } + return new NoopSpan(); + } + } + + /// + /// Reads the ids of a freshly registered native span. If a read throws, runs (once) + /// before the exception propagates, so the registry entry is not leaked by a constructor that never completes. + /// + public static void ReadIds(Func spanId, Func traceId, Action release, out string span, out string trace) + { + try + { + span = spanId() ?? ""; + trace = traceId() ?? ""; + } + catch + { + release(); + throw; + } + } + } +} diff --git a/Runtime/WrapperPolicy/SpanStart.cs.meta b/Runtime/WrapperPolicy/SpanStart.cs.meta new file mode 100644 index 0000000..27fd692 --- /dev/null +++ b/Runtime/WrapperPolicy/SpanStart.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4cb0167ff1af4e249434d735832f1d38 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/SpanStartTests.cs b/Tests~/WrapperPolicy/SpanStartTests.cs new file mode 100644 index 0000000..b211090 --- /dev/null +++ b/Tests~/WrapperPolicy/SpanStartTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Apm; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class SpanStartTests + { + sealed class Handle : ISpanHandle + { + public int Released; + public string SpanId => "s"; + public string TraceId => "t"; + public void SetName(string name) { } + public void SetDescription(string description) { } + public void SetAttribute(string key, object value) { } + public void SetStatus(SpanStatus status) { } + public ISpanHandle StartChild(string operation, string description) => null; + public void Finish(SpanStatus status) { } + public void Release() => Released++; + } + + [Test] + public void A_throwing_native_start_warns_and_returns_an_inert_span() + { + var warnings = new List(); + ISpan span = null; + Assert.DoesNotThrow(() => span = SpanStart.Transaction(() => throw new InvalidOperationException("jni"), "op", warnings.Add)); + Assert.That(span, Is.InstanceOf()); + Assert.That(warnings.Count, Is.EqualTo(1)); + Assert.That(warnings[0], Does.Contain("jni")); + } + + [Test] + public void A_null_handle_is_inert_without_a_warning() + { + var warnings = new List(); + Assert.That(SpanStart.Transaction(() => null, "op", warnings.Add), Is.InstanceOf()); + Assert.That(warnings, Is.Empty); + } + + [Test] + public void A_handle_is_wrapped() + { + var handle = new Handle(); + var span = SpanStart.Transaction(() => handle, "op", null); + Assert.That(span, Is.InstanceOf()); + Assert.That(span.SpanId, Is.EqualTo("s")); + } + + [Test] + public void A_throwing_ids_read_releases_the_handle_once_and_rethrows() + { + int released = 0; + Assert.Throws(() => + SpanStart.ReadIds(() => "s", () => throw new InvalidOperationException("read"), () => released++, out _, out _)); + Assert.That(released, Is.EqualTo(1)); + released = 0; + Assert.Throws(() => + SpanStart.ReadIds(() => throw new InvalidOperationException("read"), () => "t", () => released++, out _, out _)); + Assert.That(released, Is.EqualTo(1)); + } + + [Test] + public void Ids_are_read_without_releasing_and_null_reads_as_empty() + { + int released = 0; + SpanStart.ReadIds(() => null, () => "t", () => released++, out var span, out var trace); + Assert.That(span, Is.EqualTo("")); + Assert.That(trace, Is.EqualTo("t")); + Assert.That(released, Is.EqualTo(0)); + } + } +} From 2808c7fd41242be67e495cbcb95f72d283903f3e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:42:36 +0500 Subject: [PATCH 119/157] Add an opt-in, experimental Android Unity frame pusher (DirectBuffers). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BugseeFrameCapturer captures Unity's screen with ScreenCapture + AsyncGPUReadback and pushes it through VideoFrameConsumer.addFrame (Bugsee Android 7.4.1). Off unless Enable() is called. Throttling, in-flight limit, readback gating, row order, capture size and secure-rect blanking live in pure, unit-tested policy classes. Not benchmarked on devices. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + Documentation~/index.md | 25 + Runtime/Bugsee.Runtime.asmdef | 8 +- Runtime/Capture.meta | 8 + Runtime/Capture/BugseeFrameCapturer.cs | 454 ++++++++++++++++++ Runtime/Capture/BugseeFrameCapturer.cs.meta | 11 + Runtime/WrapperPolicy/FramePushPolicy.cs | 173 +++++++ Runtime/WrapperPolicy/FramePushPolicy.cs.meta | 11 + Runtime/WrapperPolicy/FrameRedaction.cs | 62 +++ Runtime/WrapperPolicy/FrameRedaction.cs.meta | 11 + .../BugseeHarness/BugseeSampleBootstrap.cs | 4 + .../BugseeMiniGame/Packages/manifest.json | 1 + .../Bugsee.WrapperPolicy.Tests.csproj | 1 + Tests~/WrapperPolicy/FramePushPolicyTests.cs | 262 ++++++++++ Tools~/scripts/compile-check.sh | 5 +- 15 files changed, 1034 insertions(+), 3 deletions(-) create mode 100644 Runtime/Capture.meta create mode 100644 Runtime/Capture/BugseeFrameCapturer.cs create mode 100644 Runtime/Capture/BugseeFrameCapturer.cs.meta create mode 100644 Runtime/WrapperPolicy/FramePushPolicy.cs create mode 100644 Runtime/WrapperPolicy/FramePushPolicy.cs.meta create mode 100644 Runtime/WrapperPolicy/FrameRedaction.cs create mode 100644 Runtime/WrapperPolicy/FrameRedaction.cs.meta create mode 100644 Tests~/WrapperPolicy/FramePushPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68332de..77caed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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`, reused buffers, one request in flight, frames dropped meanwhile, throttled to `TargetFps`, default 5). It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (the SDK does not mask them); blackout is honoured by the SDK. Readback failures turn it off (no synchronous fallback), and Vulkan needs Unity 2021.3.18 or newer. **Not benchmarked on devices, and the readback row order per graphics API is unverified** (`BugseeFrameCapturerOptions.RowOrder` overrides it). Needs the `com.unity.modules.screencapture` module (compiled out without it). Nothing changes unless you call `Enable()`: the default capture stays the SDK's own (`VideoMode.V2`), and a launch with `DirectBuffers` but no `Enable()` gets no frames from Unity (nothing pushes them). Refs #22 (benchmarking V2 against this on real 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index 4530022..e6b0754 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -229,3 +229,28 @@ Wired: Launch/Stop, blackout, log/trace/event, exceptions (JSON-in-reason), Test Both native SDKs have `startTransaction` / `startSpan` / `getActiveSpan`, but the Unity package does not expose them yet: a span is a live native object (it needs a handle that crosses JNI and P/Invoke and a lifetime rule), the active span is per native thread (not propagated across `dispatch_async` on iOS), and the no-op behaviour when APM is disabled is only documented on iOS. See the proposal on the tracking issue. Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). + +## Experimental: Unity frame pusher (Android) + +Not benchmarked on devices; do not enable it in production without measuring. Nothing here changes unless you opt in: by default the Android SDK captures the window itself (`VideoMode.V2`), and `BugseeFrameCapturer` does not exist at runtime until you call `Enable()`. + +```csharp +// using Bugsee.Capture; +#if UNITY_ANDROID && !UNITY_EDITOR +Bugsee.Launch(token, new AndroidLaunchOptions { CaptureVideo = true, CaptureVideoMode = VideoMode.DirectBuffers }); +BugseeFrameCapturer.Enable(new BugseeFrameCapturerOptions { TargetFps = 5, MaxDimension = 640 }); +#endif +``` + +How it works: at the end of each due frame it calls `ScreenCapture.CaptureScreenshotIntoRenderTexture`, scales into a small RenderTexture and reads it back with `AsyncGPUReadback` (never a synchronous read). One request is in flight at a time; frames that come due meanwhile are dropped, not queued. Buffers are reused; the pixels reach the SDK's `VideoFrameConsumer.addFrame` from a background thread with the capture time as the timestamp. `BugseeFrameCapturer.Stats` and a log line every 10 s show requested, pushed, failed and dropped counts. + +What to know before using it: + +- Needs the Screen Capture built-in module (`com.unity.modules.screencapture`, on by default in new projects); without it the capturer is compiled out and `Enable()` returns false. Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. +- It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. +- Redaction: the SDK does not mask frames pushed this way. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle` (screen pixels, top-left origin). Secure native views are not known to it. Blackout is honoured by the SDK. +- Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (crash in older builds). After 5 failed readbacks in a row it turns itself off. +- Row order of the readback is unverified per graphics API. It defaults to top-down when `SystemInfo.graphicsUVStartsAtTop`, bottom-up otherwise; if video comes out upside down, set `BugseeFrameCapturerOptions.RowOrder`. +- Unity cannot hand native memory to Java as a `ByteBuffer`, so each pushed frame costs one Java `byte[]` (about 0.7 MB at 640 px) plus a copy into a reused direct buffer. Lower `MaxDimension` or `TargetFps` to reduce it. The secure-rectangle snapshot also allocates a few ints per frame. +- The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. +- Exercised only on an Android 16 arm64 emulator (IL2CPP development build): frames were pushed and the SDK wrote video exports. Video orientation and content were not inspected, and there are no device or performance numbers. diff --git a/Runtime/Bugsee.Runtime.asmdef b/Runtime/Bugsee.Runtime.asmdef index fa4255b..90188cc 100644 --- a/Runtime/Bugsee.Runtime.asmdef +++ b/Runtime/Bugsee.Runtime.asmdef @@ -9,6 +9,12 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.modules.screencapture", + "expression": "", + "define": "BUGSEE_HAS_SCREENCAPTURE" + } + ], "noEngineReferences": false } diff --git a/Runtime/Capture.meta b/Runtime/Capture.meta new file mode 100644 index 0000000..36cb26b --- /dev/null +++ b/Runtime/Capture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 276bcdb5b15341c4bb68ea531e0de31f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs new file mode 100644 index 0000000..d34a9cf --- /dev/null +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Capture; +using Bugsee.Contracts.Options; +using Bugsee.WrapperPolicy; +using UnityEngine; + +#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE +using System.Threading; +using Unity.Collections; +using UnityEngine.Rendering; +#endif + +namespace Bugsee.Capture +{ + /// Settings for the experimental Unity frame pusher. + public sealed class BugseeFrameCapturerOptions + { + /// Frames per second to capture, 1..30. The SDK also caps pushed frames by its own FrameRate option. + public int TargetFps = FramePushPolicy.DefaultFps; + + /// Longer side of the captured frame in pixels. The screen is scaled down to this, never up. + public int MaxDimension = 640; + + /// + /// Row order of the readback buffer. Null picks it from the graphics API (see FramePushPolicy.RowOrderFor). + /// Set it if recorded video comes out upside down. + /// + public VideoFrameRowOrder? RowOrder; + } + + /// Counters of the experimental frame pusher. + public struct BugseeFrameCapturerStats + { + public long Requested, Pushed, Failed, DroppedInFlight, DroppedBlackoutOrStopped; + } + + /// + /// EXPERIMENTAL, Android only, off unless is called. Captures Unity's final composite + /// (ScreenCapture + AsyncGPUReadback, screen-space overlay UI included, native Android views not) and pushes it to + /// the SDK through VideoFrameConsumer.addFrame. Needs the SDK launched with VideoMode.DirectBuffers; in any other + /// mode the SDK ignores the frames. Not benchmarked on devices. + /// + /// One request is in flight at a time and frames that arrive meanwhile are dropped. Buffers are reused. Readback + /// failures turn the pusher off; it never falls back to a synchronous read. Secure rectangles registered with + /// Bugsee.AddSecureRectangle are blanked here, because the SDK does not mask pushed frames (blackout it does honour). + /// + public sealed class BugseeFrameCapturer : MonoBehaviour + { + static BugseeFrameCapturer _instance; + + /// True once started the pusher on this platform and it has not been disabled. + public static bool IsEnabled => _instance != null && _instance._enabled; + + public static BugseeFrameCapturerStats Stats => + _instance != null ? _instance.ReadStats() : default; + + /// + /// Starts the pusher. Returns false (and does nothing) off Android, in the Editor, or when the device cannot do + /// GPU readback. Call after or around Bugsee.Launch with VideoMode.DirectBuffers; frames are skipped until the + /// SDK is launched. Main thread only. + /// + public static bool Enable(BugseeFrameCapturerOptions options = null) + { +#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE + if (!Internal.MainThreadDispatcher.IsMainThread) + { + Internal.MainThreadDispatcher.Run(() => Enable(options)); + return true; + } + if (_instance != null && _instance._enabled) return true; + + bool vulkan = SystemInfo.graphicsDeviceType == GraphicsDeviceType.Vulkan; + if (!FramePushPolicy.IsReadbackUsable(SystemInfo.supportsAsyncGPUReadback, vulkan, Application.unityVersion)) + { + Debug.LogWarning("[BugseeFrameCapturer] GPU readback is not usable here (unsupported, or Vulkan on Unity older than 2021.3.18); not enabled."); + return false; + } + + var go = new GameObject("Bugsee.FrameCapturer") { hideFlags = HideFlags.HideAndDontSave }; + DontDestroyOnLoad(go); + var capturer = go.AddComponent(); + if (!capturer.Begin(options ?? new BugseeFrameCapturerOptions())) + { + Destroy(go); + return false; + } + _instance = capturer; + return true; +#else + Debug.LogWarning("[BugseeFrameCapturer] Android player builds with the Screen Capture module only; not enabled."); + return false; +#endif + } + + /// Stops the pusher and releases its buffers once any push in flight has finished. + public static void Disable() + { + if (_instance == null) return; + _instance.Stop(); + } + + bool _enabled; + FramePushPolicy _policy; + + BugseeFrameCapturerStats ReadStats() + { + if (_policy == null) return default; + _policy.Snapshot(out long req, out long pushed, out long failed, out long inFlight, out long blackout); + return new BugseeFrameCapturerStats + { + Requested = req, Pushed = pushed, Failed = failed, + DroppedInFlight = inFlight, DroppedBlackoutOrStopped = blackout + }; + } + +#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE + const string ConsumerClass = "com/bugsee/library/contracts/capture/consumers/VideoFrameConsumer"; + const string RowOrderClass = ConsumerClass + "$RowOrder"; + const string AddFrameSignature = "(JILjava/nio/ByteBuffer;IIIL" + RowOrderClass + ";)V"; + const float StatsLogIntervalSeconds = 10f; + + static readonly WaitForEndOfFrame EndOfFrame = new WaitForEndOfFrame(); + + BugseeFrameCapturerOptions _options; + VideoFrameRowOrder _rowOrder; + Action _onReadback; + + RenderTexture _fullRt, _smallRt; + int _captureW, _captureH; + long _requestTimestampMs; + + // Pixels: the readback callback (main thread) fills _pixels, the push thread copies them into the Java + // direct buffer and calls addFrame. Unity's AndroidJNI cannot wrap or address native memory as a ByteBuffer, + // so the copy goes through a Java byte[] (one Java-heap array per pushed frame, no managed allocation). Only one request is in flight, so neither side overwrites the other. + byte[] _pixels; + IntPtr _directBuffer; // global ref to a reused direct java.nio.ByteBuffer + IntPtr _bufferClear, _bufferRewind, _bufferPut; // method ids on java.nio.Buffer / ByteBuffer + IntPtr _consumer; // global ref to the VideoFrameConsumer + IntPtr _addFrameMethod; + IntPtr _topDown, _bottomUp; // global refs to the RowOrder constants + readonly jvalue[] _args = new jvalue[7]; + readonly jvalue[] _putArgs = new jvalue[1]; + readonly jvalue[] _noArgs = new jvalue[0]; + bool _wasLaunched; + bool _warnedMode; + bool _paused; + bool _releaseDone; + float _nextStatsLog; + + Thread _worker; + readonly SemaphoreSlim _signal = new SemaphoreSlim(0); + volatile bool _stopWorker; + int _pushW, _pushH; + VideoFrameRowOrder _pushOrder; + + bool Begin(BugseeFrameCapturerOptions options) + { + _options = options; + _policy = new FramePushPolicy(options.TargetFps); + _rowOrder = FramePushPolicy.RowOrderFor(SystemInfo.graphicsUVStartsAtTop, options.RowOrder); + _onReadback = OnReadback; + try + { + _topDown = RowOrderConstant("TOP_DOWN"); + _bottomUp = RowOrderConstant("BOTTOM_UP"); + IntPtr cls = AndroidJNI.FindClass(ConsumerClass); + _addFrameMethod = AndroidJNI.GetMethodID(cls, "addFrame", AddFrameSignature); + AndroidJNI.DeleteLocalRef(cls); + IntPtr bufferCls = AndroidJNI.FindClass("java/nio/Buffer"); + _bufferClear = AndroidJNI.GetMethodID(bufferCls, "clear", "()Ljava/nio/Buffer;"); + _bufferRewind = AndroidJNI.GetMethodID(bufferCls, "rewind", "()Ljava/nio/Buffer;"); + AndroidJNI.DeleteLocalRef(bufferCls); + IntPtr byteBufferCls = AndroidJNI.FindClass("java/nio/ByteBuffer"); + _bufferPut = AndroidJNI.GetMethodID(byteBufferCls, "put", "([B)Ljava/nio/ByteBuffer;"); + AndroidJNI.DeleteLocalRef(byteBufferCls); + if (_bufferClear == IntPtr.Zero || _bufferRewind == IntPtr.Zero || _bufferPut == IntPtr.Zero + || _addFrameMethod == IntPtr.Zero || _topDown == IntPtr.Zero || _bottomUp == IntPtr.Zero) + { + AndroidJNI.ExceptionClear(); + Debug.LogWarning("[BugseeFrameCapturer] VideoFrameConsumer.addFrame not found; this needs Bugsee Android 7.4.1 or newer."); + ReleaseNative(); + return false; + } + } + catch (Exception ex) + { + Debug.LogWarning("[BugseeFrameCapturer] JNI setup failed: " + ex.Message); + ReleaseNative(); + return false; + } + + _enabled = true; + _worker = new Thread(PushLoop) { IsBackground = true, Name = "Bugsee.FramePush" }; + _worker.Start(); + StartCoroutine(CaptureLoop()); + return true; + } + + static IntPtr RowOrderConstant(string name) + { + IntPtr cls = AndroidJNI.FindClass(RowOrderClass); + IntPtr field = AndroidJNI.GetStaticFieldID(cls, name, "L" + RowOrderClass + ";"); + IntPtr local = AndroidJNI.GetStaticObjectField(cls, field); + IntPtr global = local == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.NewGlobalRef(local); + AndroidJNI.DeleteLocalRef(local); + AndroidJNI.DeleteLocalRef(cls); + return global; + } + + void Stop() + { + _enabled = false; + StopAllCoroutines(); + } + + void OnApplicationPause(bool paused) => _paused = paused; + + // Releases buffers once disabled and nothing is in flight. Runs on the main thread (RenderTexture.Release needs it). + void Update() + { + if (_enabled) + { + if (Time.unscaledTime >= _nextStatsLog) + { + _nextStatsLog = Time.unscaledTime + StatsLogIntervalSeconds; + var s = ReadStats(); + Debug.Log("[BugseeFrameCapturer] requested=" + s.Requested + " pushed=" + s.Pushed + " failed=" + s.Failed + + " droppedInFlight=" + s.DroppedInFlight + " droppedBlackoutOrStopped=" + s.DroppedBlackoutOrStopped + + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder); + } + if (_policy.IsDisabled) _enabled = false; + return; + } + if (_releaseDone || _policy.HasInFlight) return; + _releaseDone = true; + _stopWorker = true; + _signal.Release(); + ReleaseRenderTextures(); + ReleaseNative(); + if (_instance == this) _instance = null; + Destroy(gameObject); + } + + System.Collections.IEnumerator CaptureLoop() + { + while (_enabled) + { + yield return EndOfFrame; + if (!_enabled) break; + try + { + Tick(); + } + catch (Exception ex) + { + // An exception would end the coroutine and silently stop capture. + Debug.LogWarning("[BugseeFrameCapturer] tick failed: " + ex.Message); + } + } + } + + void Tick() + { + double now = Time.realtimeSinceStartupAsDouble; + if (_paused || !_policy.IsDue(now)) return; + + // JNI calls only once per due frame, not on every game frame. + bool launched = Bugsee.IsLaunched; + bool stopped = !launched || Bugsee.IsBlackout; + if (launched && !_wasLaunched && !_policy.HasInFlight) RefreshConsumer(); + _wasLaunched = launched; + stopped |= _consumer == IntPtr.Zero; + + if (_policy.TryBegin(now, stopped) != FramePushDecision.Request) return; + + try + { + if (!EnsureBuffers()) { _policy.Complete(false); return; } + _requestTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ScreenCapture.CaptureScreenshotIntoRenderTexture(_fullRt); + Graphics.Blit(_fullRt, _smallRt); + AsyncGPUReadback.Request(_smallRt, 0, TextureFormat.RGBA32, _onReadback); + } + catch (Exception ex) + { + Debug.LogWarning("[BugseeFrameCapturer] capture request failed: " + ex.Message); + _policy.Complete(false); + } + } + + void RefreshConsumer() + { + if (_consumer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_consumer); _consumer = IntPtr.Zero; } + using (var bugsee = new AndroidJavaClass("com.bugsee.library.Bugsee")) + using (var consumer = bugsee.CallStatic("getVideoFrameConsumer")) + { + if (consumer != null) _consumer = AndroidJNI.NewGlobalRef(consumer.GetRawObject()); + } + if (_warnedMode) return; + _warnedMode = true; + IReadOnlyDictionary launch = Bugsee.GetLaunchOptions(); + // The SDK reports option values as strings. + if (launch != null && launch.TryGetValue(Options.CaptureVideoMode, out object mode) && mode != null + && int.TryParse(Convert.ToString(mode, System.Globalization.CultureInfo.InvariantCulture), out int modeValue) + && VideoModeExtensions.FromIntValue(modeValue) != VideoMode.DirectBuffers) + Debug.LogWarning("[BugseeFrameCapturer] the SDK is not launched with VideoMode.DirectBuffers; pushed frames are ignored."); + } + + bool EnsureBuffers() + { + FramePushPolicy.ComputeCaptureSize(Screen.width, Screen.height, _options.MaxDimension, out int w, out int h); + bool fullStale = _fullRt == null || _fullRt.width != Screen.width || _fullRt.height != Screen.height; + if (!fullStale && w == _captureW && h == _captureH && _smallRt != null && _pixels != null) return true; + + // Nothing is in flight here (this request is the only one), so the push thread is idle. + ReleaseRenderTextures(); + _fullRt = new RenderTexture(Mathf.Max(2, Screen.width), Mathf.Max(2, Screen.height), 0, RenderTextureFormat.ARGB32); + _smallRt = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32); + _fullRt.Create(); + _smallRt.Create(); + _captureW = w; + _captureH = h; + + int bytes = (int)FramePushPolicy.FrameBytes(w, h); + _pixels = new byte[bytes]; + ReleaseDirectBuffer(); + IntPtr cls = AndroidJNI.FindClass("java/nio/ByteBuffer"); + IntPtr alloc = AndroidJNI.GetStaticMethodID(cls, "allocateDirect", "(I)Ljava/nio/ByteBuffer;"); + var arg = new jvalue[1]; + arg[0].i = bytes; + IntPtr local = AndroidJNI.CallStaticObjectMethod(cls, alloc, arg); + AndroidJNI.DeleteLocalRef(cls); + if (local == IntPtr.Zero) { AndroidJNI.ExceptionClear(); return false; } + _directBuffer = AndroidJNI.NewGlobalRef(local); + AndroidJNI.DeleteLocalRef(local); + return true; + } + + void OnReadback(AsyncGPUReadbackRequest request) + { + if (!_enabled || request.hasError || _pixels == null) + { + _policy.Complete(false); + return; + } + NativeArray data = request.GetData(); + if (data.Length < _pixels.Length) { _policy.Complete(false); return; } + NativeArray.Copy(data, _pixels, _pixels.Length); + + // The SDK does not mask pushed frames: blank the registered secure rectangles first. + int[] rects = SecureRectRegistry.Instance.Snapshot(0, 1f); + FrameRedaction.BlankRects(_pixels, _captureW, _captureH, _rowOrder, rects, Screen.width, Screen.height); + + _pushW = _captureW; + _pushH = _captureH; + _pushOrder = _rowOrder; + _signal.Release(); + } + + // Push thread: one frame per signal. addFrame converts synchronously on this thread, so the direct buffer can + // be reused as soon as it returns. + void PushLoop() + { + AndroidJNI.AttachCurrentThread(); + try + { + while (true) + { + _signal.Wait(); + if (_stopWorker) break; + _policy.Complete(PushOne()); + } + } + finally + { + AndroidJNI.DetachCurrentThread(); + } + } + + bool PushOne() + { + try + { + IntPtr java = AndroidJNI.ToByteArray(_pixels); + try + { + _putArgs[0].l = java; + AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferClear, _noArgs)); + AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferPut, _putArgs)); + if (AndroidJNI.ExceptionOccurred() != IntPtr.Zero) + { + AndroidJNI.ExceptionClear(); + return false; + } + AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferRewind, _noArgs)); + } + finally + { + AndroidJNI.DeleteLocalRef(java); + } + _args[0].j = _requestTimestampMs; + _args[1].i = 0; // display id + _args[2].l = _directBuffer; + _args[3].i = _pushW; + _args[4].i = _pushH; + _args[5].i = FramePushPolicy.PixelStride; + _args[6].l = _pushOrder == VideoFrameRowOrder.BottomUp ? _bottomUp : _topDown; + AndroidJNI.CallVoidMethod(_consumer, _addFrameMethod, _args); + if (AndroidJNI.ExceptionOccurred() != IntPtr.Zero) + { + AndroidJNI.ExceptionClear(); + return false; + } + return true; + } + catch (Exception) + { + return false; + } + } + + void ReleaseRenderTextures() + { + if (_fullRt != null) { _fullRt.Release(); Destroy(_fullRt); _fullRt = null; } + if (_smallRt != null) { _smallRt.Release(); Destroy(_smallRt); _smallRt = null; } + } + + void ReleaseDirectBuffer() + { + if (_directBuffer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_directBuffer); _directBuffer = IntPtr.Zero; } + } + + void ReleaseNative() + { + ReleaseDirectBuffer(); + if (_consumer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_consumer); _consumer = IntPtr.Zero; } + if (_topDown != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_topDown); _topDown = IntPtr.Zero; } + if (_bottomUp != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_bottomUp); _bottomUp = IntPtr.Zero; } + _pixels = null; + } + + void OnDestroy() + { + _enabled = false; + _stopWorker = true; + _signal.Release(); + if (_instance == this) _instance = null; + } +#else + void Stop() { _enabled = false; } +#endif + } +} diff --git a/Runtime/Capture/BugseeFrameCapturer.cs.meta b/Runtime/Capture/BugseeFrameCapturer.cs.meta new file mode 100644 index 0000000..7f34418 --- /dev/null +++ b/Runtime/Capture/BugseeFrameCapturer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 13020568ec5040c6b4e7967c464bb9e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/FramePushPolicy.cs b/Runtime/WrapperPolicy/FramePushPolicy.cs new file mode 100644 index 0000000..b61b8e3 --- /dev/null +++ b/Runtime/WrapperPolicy/FramePushPolicy.cs @@ -0,0 +1,173 @@ +using System; +using Bugsee.Contracts.Capture; + +namespace Bugsee.WrapperPolicy +{ + public enum FramePushDecision + { + /// Start a capture now. + Request, + /// Too soon after the previous request for the configured frame rate. + Throttled, + /// A readback (or the push of its pixels) is still in flight: this frame is dropped, not queued. + InFlight, + /// The SDK is blacked out or not launched: nothing is captured. + Blackout, + /// Capture turned itself off after repeated readback failures. It never falls back to a synchronous read. + Disabled + } + + /// + /// When the experimental Unity frame pusher (BugseeFrameCapturer) may start a capture, plus the size and pixel + /// format rules it needs. Pure logic (no Unity types) so it can be unit-tested. Thread-safe: the main thread + /// begins a request and the push thread completes it. + /// + public sealed class FramePushPolicy + { + public const int MinFps = 1; + public const int MaxFps = 30; + public const int DefaultFps = 5; + + /// RGBA32 is the only format the pusher requests; the SDK reads it with a pixel stride of 4. + public const int PixelStride = 4; + + readonly object _gate = new object(); + readonly double _intervalSeconds; + readonly int _maxInFlight; + readonly int _maxConsecutiveFailures; + + double _nextDueSeconds = double.NegativeInfinity; + int _inFlight; + int _consecutiveFailures; + bool _disabled; + long _requested, _pushed, _failed, _droppedInFlight, _droppedBlackout; + + public FramePushPolicy(int targetFps = DefaultFps, int maxInFlight = 1, int maxConsecutiveFailures = 5) + { + int fps = Math.Min(MaxFps, Math.Max(MinFps, targetFps)); + _intervalSeconds = 1.0 / fps; + _maxInFlight = Math.Max(1, maxInFlight); + _maxConsecutiveFailures = Math.Max(1, maxConsecutiveFailures); + } + + public bool HasInFlight { get { lock (_gate) return _inFlight > 0; } } + public bool IsDisabled { get { lock (_gate) return _disabled; } } + + /// Cheap pre-check (no side effects): is the frame-rate interval over? Lets the caller skip JNI work on most game frames. + public bool IsDue(double nowSeconds) + { + lock (_gate) return !_disabled && nowSeconds >= _nextDueSeconds; + } + + /// Decide for this frame and, on , record the request as in flight. + public FramePushDecision TryBegin(double nowSeconds, bool blackout) + { + lock (_gate) + { + if (_disabled) return FramePushDecision.Disabled; + if (nowSeconds < _nextDueSeconds) return FramePushDecision.Throttled; + if (_inFlight >= _maxInFlight) + { + _droppedInFlight++; + return FramePushDecision.InFlight; + } + if (blackout) + { + _droppedBlackout++; + return FramePushDecision.Blackout; + } + _inFlight++; + _requested++; + _nextDueSeconds = nowSeconds + _intervalSeconds; + return FramePushDecision.Request; + } + } + + /// A started request finished: pushed (true) or failed/dropped after starting (false). + public void Complete(bool pushed) + { + lock (_gate) + { + if (_inFlight > 0) _inFlight--; + if (pushed) + { + _pushed++; + _consecutiveFailures = 0; + return; + } + _failed++; + if (++_consecutiveFailures >= _maxConsecutiveFailures) _disabled = true; + } + } + + public void Snapshot(out long requested, out long pushed, out long failed, out long droppedInFlight, out long droppedBlackout) + { + lock (_gate) + { + requested = _requested; + pushed = _pushed; + failed = _failed; + droppedInFlight = _droppedInFlight; + droppedBlackout = _droppedBlackout; + } + } + + /// Capture size: the screen scaled down so its longer side is at most , never up, even and at least 2. + public static void ComputeCaptureSize(int screenWidth, int screenHeight, int maxDimension, out int width, out int height) + { + int sw = Math.Max(2, screenWidth); + int sh = Math.Max(2, screenHeight); + int cap = Math.Max(2, maxDimension); + double scale = Math.Min(1.0, (double)cap / Math.Max(sw, sh)); + width = Even((int)Math.Round(sw * scale)); + height = Even((int)Math.Round(sh * scale)); + } + + static int Even(int v) => Math.Max(2, v & ~1); + + /// Bytes the SDK expects for a pushed frame (width * height * stride); it drops a buffer smaller than this. + public static long FrameBytes(int width, int height) => (long)width * height * PixelStride; + + /// + /// True when GPU readback may be used. Unity on Android with Vulkan crashed in AsyncGPUReadback from 2020.3 + /// through 2021.3.17 (fixed in 2021.3.18f1), so Vulkan needs 2021.3.18+ (or any later major). Unparsable + /// versions are rejected on Vulkan. The pusher never falls back to a synchronous ReadPixels. + /// + public static bool IsReadbackUsable(bool supportsAsyncReadback, bool isVulkan, string unityVersion) + { + if (!supportsAsyncReadback) return false; + if (!isVulkan) return true; + return TryParseUnityVersion(unityVersion, out int major, out int minor, out int patch) + && (major > 2021 || (major == 2021 && (minor > 3 || (minor == 3 && patch >= 18)))); + } + + /// Parses "2021.3.45f1" (and "6000.0.1b2") into major, minor, patch. + public static bool TryParseUnityVersion(string version, out int major, out int minor, out int patch) + { + major = minor = patch = 0; + if (string.IsNullOrEmpty(version)) return false; + string[] parts = version.Split('.'); + if (parts.Length < 3) return false; + int end = 0; + while (end < parts[2].Length && char.IsDigit(parts[2][end])) end++; + return int.TryParse(parts[0], out major) + && int.TryParse(parts[1], out minor) + && end > 0 + && int.TryParse(parts[2].Substring(0, end), out patch); + } + + /// + /// Row order of the readback buffer. Textures whose UV origin is at the top (Vulkan, Metal, D3D) read back + /// top row first; GL-style textures read back bottom row first. An explicit override wins. Not verified on + /// devices: ScreenCapture's own orientation per graphics API is undocumented. + /// + public static VideoFrameRowOrder RowOrderFor( + bool uvStartsAtTop, VideoFrameRowOrder? overrideOrder) + { + if (overrideOrder.HasValue) return overrideOrder.Value; + return uvStartsAtTop + ? VideoFrameRowOrder.TopDown + : VideoFrameRowOrder.BottomUp; + } + } +} diff --git a/Runtime/WrapperPolicy/FramePushPolicy.cs.meta b/Runtime/WrapperPolicy/FramePushPolicy.cs.meta new file mode 100644 index 0000000..1757bd4 --- /dev/null +++ b/Runtime/WrapperPolicy/FramePushPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 39435e0b243443e9bde1148a3606c3bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs new file mode 100644 index 0000000..3088e51 --- /dev/null +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -0,0 +1,62 @@ +using Bugsee.Contracts.Capture; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Blanks secure rectangles in a pushed RGBA frame. The SDK does not mask frames pushed through DirectBuffers + /// (only blackout is honoured), so the pusher has to, using the rectangles registered with + /// Bugsee.AddSecureRectangle. + /// + public static class FrameRedaction + { + /// + /// Fills each rectangle with opaque black. is the output of + /// SecureRectRegistry.Snapshot ([version, count, l, t, r, b, ...]) in screen pixels with a top-left origin; + /// it is scaled from x to the frame, and the + /// rows are mirrored when the buffer is bottom-up. Rounds outward (floor/ceil) so a mask never shrinks. + /// Returns the number of rectangles that touched the frame. No allocations. + /// + public static int BlankRects( + byte[] rgba, int width, int height, VideoFrameRowOrder order, + int[] packedSnapshot, int screenWidth, int screenHeight) + { + if (rgba == null || packedSnapshot == null || packedSnapshot.Length < 2) return 0; + if (width <= 0 || height <= 0 || screenWidth <= 0 || screenHeight <= 0) return 0; + if (rgba.LongLength < (long)width * height * FramePushPolicy.PixelStride) return 0; + + int count = packedSnapshot[1]; + if (count < 0 || packedSnapshot.Length < 2 + count * 4) return 0; + + double sx = (double)width / screenWidth; + double sy = (double)height / screenHeight; + int applied = 0; + for (int i = 0; i < count; i++) + { + int o = 2 + i * 4; + int x0 = Clamp((int)System.Math.Floor(packedSnapshot[o] * sx), 0, width); + int y0 = Clamp((int)System.Math.Floor(packedSnapshot[o + 1] * sy), 0, height); + int x1 = Clamp((int)System.Math.Ceiling(packedSnapshot[o + 2] * sx), 0, width); + int y1 = Clamp((int)System.Math.Ceiling(packedSnapshot[o + 3] * sy), 0, height); + if (x1 <= x0 || y1 <= y0) continue; + + for (int y = y0; y < y1; y++) + { + int row = order == VideoFrameRowOrder.BottomUp ? height - 1 - y : y; + int p = (row * width + x0) * FramePushPolicy.PixelStride; + for (int x = x0; x < x1; x++) + { + rgba[p] = 0; + rgba[p + 1] = 0; + rgba[p + 2] = 0; + rgba[p + 3] = 255; + p += FramePushPolicy.PixelStride; + } + } + applied++; + } + return applied; + } + + static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + } +} diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs.meta b/Runtime/WrapperPolicy/FrameRedaction.cs.meta new file mode 100644 index 0000000..61e8424 --- /dev/null +++ b/Runtime/WrapperPolicy/FrameRedaction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a07cda0071264a48832c8bb3dd285093 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 02c6f89..838a14e 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -24,6 +24,8 @@ public sealed class BugseeSampleBootstrap : MonoBehaviour [SerializeField] bool launchOnStart = true; [SerializeField] bool installReportHandler; [SerializeField] bool installSampleFilters; + /// Experimental Android frame pusher (off by default); an intent extra "bugsee_frame_pusher" = 1 also turns it on. + [SerializeField] bool experimentalFramePusher; readonly Queue _lifecycleLog = new Queue(); const int MaxLifecycleEntries = 24; @@ -123,6 +125,8 @@ public void LaunchSdk() if (!string.IsNullOrWhiteSpace(endpointOverride)) options.Endpoint = endpointOverride.Trim(); Bugsee.Launch(appToken, options); + if (experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1") + global::Bugsee.Capture.BugseeFrameCapturer.Enable(); #elif UNITY_IOS && !UNITY_EDITOR var iosOptions = new IOSLaunchOptions { diff --git a/Samples~/BugseeMiniGame/Packages/manifest.json b/Samples~/BugseeMiniGame/Packages/manifest.json index 21573eb..e257038 100644 --- a/Samples~/BugseeMiniGame/Packages/manifest.json +++ b/Samples~/BugseeMiniGame/Packages/manifest.json @@ -8,6 +8,7 @@ "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0", "com.unity.modules.physics": "1.0.0", + "com.unity.modules.screencapture": "1.0.0", "com.unity.modules.ui": "1.0.0", "com.unity.modules.uielements": "1.0.0" } diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 08cf8b6..651ed73 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs new file mode 100644 index 0000000..5230222 --- /dev/null +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -0,0 +1,262 @@ +using System.IO; +using Bugsee.Contracts.Capture; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class FramePushPolicyTests + { + [Test] + public void First_frame_is_requested_then_throttled_until_the_interval_passes() + { + var p = new FramePushPolicy(targetFps: 5); // 0.2 s + Assert.That(p.TryBegin(10.0, false), Is.EqualTo(FramePushDecision.Request)); + p.Complete(true); + Assert.That(p.TryBegin(10.1, false), Is.EqualTo(FramePushDecision.Throttled)); + Assert.That(p.IsDue(10.1), Is.False); + Assert.That(p.IsDue(10.2), Is.True); + Assert.That(p.TryBegin(10.2, false), Is.EqualTo(FramePushDecision.Request)); + } + + [Test] + public void A_frame_while_one_is_in_flight_is_dropped_not_queued() + { + var p = new FramePushPolicy(targetFps: 10); + Assert.That(p.TryBegin(0, false), Is.EqualTo(FramePushDecision.Request)); + Assert.That(p.HasInFlight, Is.True); + Assert.That(p.TryBegin(1.0, false), Is.EqualTo(FramePushDecision.InFlight)); + p.Complete(true); + Assert.That(p.HasInFlight, Is.False); + Assert.That(p.TryBegin(1.0, false), Is.EqualTo(FramePushDecision.Request)); + p.Snapshot(out long req, out long pushed, out _, out long dropped, out _); + Assert.That((req, pushed, dropped), Is.EqualTo((2L, 1L, 1L))); + } + + [Test] + public void Throttle_is_checked_before_in_flight_so_slow_readbacks_do_not_inflate_the_drop_count() + { + var p = new FramePushPolicy(targetFps: 1); + p.TryBegin(0, false); + Assert.That(p.TryBegin(0.5, false), Is.EqualTo(FramePushDecision.Throttled)); + p.Snapshot(out _, out _, out _, out long dropped, out _); + Assert.That(dropped, Is.EqualTo(0)); + } + + [Test] + public void Blackout_starts_nothing_and_does_not_consume_the_interval() + { + var p = new FramePushPolicy(targetFps: 5); + Assert.That(p.TryBegin(0, true), Is.EqualTo(FramePushDecision.Blackout)); + Assert.That(p.HasInFlight, Is.False); + Assert.That(p.TryBegin(0.001, false), Is.EqualTo(FramePushDecision.Request)); + } + + [Test] + public void Repeated_failures_disable_capture_for_good_and_a_success_resets_the_streak() + { + var p = new FramePushPolicy(targetFps: 30, maxConsecutiveFailures: 3); + double t = 0; + for (int i = 0; i < 2; i++) { Assert.That(p.TryBegin(t += 1, false), Is.EqualTo(FramePushDecision.Request)); p.Complete(false); } + Assert.That(p.TryBegin(t += 1, false), Is.EqualTo(FramePushDecision.Request)); + p.Complete(true); + for (int i = 0; i < 2; i++) { Assert.That(p.TryBegin(t += 1, false), Is.EqualTo(FramePushDecision.Request)); p.Complete(false); } + Assert.That(p.IsDisabled, Is.False); + p.TryBegin(t += 1, false); + p.Complete(false); + Assert.That(p.IsDisabled, Is.True); + Assert.That(p.TryBegin(t += 1, false), Is.EqualTo(FramePushDecision.Disabled)); + Assert.That(p.IsDue(t + 100), Is.False); + } + + [TestCase(0, 1)] + [TestCase(-5, 1)] + [TestCase(1000, 30)] + public void Fps_is_clamped(int fps, int effective) + { + var p = new FramePushPolicy(fps); + p.TryBegin(0, false); + p.Complete(true); + double interval = 1.0 / effective; + Assert.That(p.IsDue(interval - 1e-6), Is.False); + Assert.That(p.IsDue(interval + 1e-6), Is.True); + } + + [TestCase(1080, 2400, 640, 288, 640)] + [TestCase(2400, 1080, 640, 640, 288)] + [TestCase(300, 200, 640, 300, 200)] // never scaled up + [TestCase(1081, 2401, 640, 288, 640)] // even sizes + [TestCase(1, 1, 640, 2, 2)] + public void Capture_size_fits_the_cap_keeps_aspect_and_is_even(int sw, int sh, int cap, int w, int h) + { + FramePushPolicy.ComputeCaptureSize(sw, sh, cap, out int cw, out int ch); + Assert.That((cw, ch), Is.EqualTo((w, h))); + Assert.That(cw % 2 == 0 && ch % 2 == 0, Is.True); + } + + [Test] + public void Frame_bytes_do_not_overflow_for_large_frames() + { + Assert.That(FramePushPolicy.FrameBytes(640, 288), Is.EqualTo(640L * 288 * 4)); + Assert.That(FramePushPolicy.FrameBytes(30000, 30000), Is.EqualTo(3_600_000_000L)); + } + + [TestCase("2021.3.45f1", 2021, 3, 45)] + [TestCase("6000.0.1b2", 6000, 0, 1)] + [TestCase("2022.3.9f1", 2022, 3, 9)] + public void Unity_version_parses(string v, int major, int minor, int patch) + { + Assert.That(FramePushPolicy.TryParseUnityVersion(v, out int a, out int b, out int c), Is.True); + Assert.That((a, b, c), Is.EqualTo((major, minor, patch))); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("2021.3")] + [TestCase("abc.def.ghi")] + public void Bad_unity_version_does_not_parse(string v) + { + Assert.That(FramePushPolicy.TryParseUnityVersion(v, out _, out _, out _), Is.False); + } + + [TestCase(false, false, "2022.3.1f1", false)] // no async readback support + [TestCase(true, false, "2019.4.1f1", true)] // GLES: version does not matter + [TestCase(true, true, "2021.3.17f1", false)] // Vulkan crash range + [TestCase(true, true, "2020.3.48f1", false)] + [TestCase(true, true, "2021.3.18f1", true)] + [TestCase(true, true, "2021.3.45f1", true)] + [TestCase(true, true, "2022.1.0b1", true)] + [TestCase(true, true, "6000.0.1f1", true)] + [TestCase(true, true, "2021.2.19f1", false)] + [TestCase(true, true, "garbage", false)] + public void Readback_gate(bool supported, bool vulkan, string version, bool expected) + { + Assert.That(FramePushPolicy.IsReadbackUsable(supported, vulkan, version), Is.EqualTo(expected)); + } + + [Test] + public void Row_order_follows_the_uv_origin_unless_overridden() + { + Assert.That(FramePushPolicy.RowOrderFor(true, null), Is.EqualTo(VideoFrameRowOrder.TopDown)); + Assert.That(FramePushPolicy.RowOrderFor(false, null), Is.EqualTo(VideoFrameRowOrder.BottomUp)); + Assert.That(FramePushPolicy.RowOrderFor(true, VideoFrameRowOrder.BottomUp), Is.EqualTo(VideoFrameRowOrder.BottomUp)); + } + } + + public class FrameRedactionTests + { + const int W = 4, H = 4; + + static byte[] White() { var b = new byte[W * H * 4]; for (int i = 0; i < b.Length; i++) b[i] = 255; return b; } + static bool Blank(byte[] b, int x, int row) { int o = (row * W + x) * 4; return b[o] == 0 && b[o + 1] == 0 && b[o + 2] == 0 && b[o + 3] == 255; } + static bool Untouched(byte[] b, int x, int row) { int o = (row * W + x) * 4; return b[o] == 255 && b[o + 1] == 255 && b[o + 2] == 255; } + static int[] One(int l, int t, int r, int bt) => new[] { 1, 1, l, t, r, bt }; + + [Test] + public void Top_left_rect_blanks_the_top_rows_of_a_top_down_frame() + { + var b = White(); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 2, 1), 4, 4), Is.EqualTo(1)); + Assert.That(Blank(b, 0, 0) && Blank(b, 1, 0), Is.True); + Assert.That(Untouched(b, 2, 0) && Untouched(b, 0, 1), Is.True); + } + + [Test] + public void Same_rect_lands_on_the_last_rows_of_a_bottom_up_frame() + { + var b = White(); + FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.BottomUp, One(0, 0, 2, 1), 4, 4); + Assert.That(Blank(b, 0, H - 1) && Blank(b, 1, H - 1), Is.True); + Assert.That(Untouched(b, 0, 0), Is.True); + } + + [Test] + public void Rect_is_scaled_from_screen_pixels_and_rounded_outward() + { + var b = White(); + // 8x8 screen, rect (1,1)-(3,3) -> frame 4x4 scale 0.5 -> floor(0.5)=0 .. ceil(1.5)=2 + FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(1, 1, 3, 3), 8, 8); + Assert.That(Blank(b, 0, 0) && Blank(b, 1, 1), Is.True); + Assert.That(Untouched(b, 2, 2), Is.True); + } + + [Test] + public void Out_of_range_and_degenerate_rects_are_clamped_or_ignored() + { + var b = White(); + var packed = new[] { 1, 3, -10, -10, 100, 100, 2, 2, 2, 3, 3, 3, 1, 1 }; + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, packed, 4, 4), Is.EqualTo(1)); + for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(b, x, y), Is.True); + } + + [Test] + public void Bad_input_changes_nothing() + { + var b = White(); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, null, 4, 4), Is.EqualTo(0)); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, new[] { 1, 5, 0, 0, 1, 1 }, 4, 4), Is.EqualTo(0)); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 0, 4), Is.EqualTo(0)); + Assert.That(FrameRedaction.BlankRects(new byte[3], W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 4, 4), Is.EqualTo(0)); + Assert.That(Untouched(b, 0, 0), Is.True); + } + } + + public class FrameCapturerSourceTests + { + static string Source() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!File.Exists(Path.Combine(dir, "package.json"))) dir = Directory.GetParent(dir).FullName; + return File.ReadAllText(Path.Combine(dir, "Runtime", "Capture", "BugseeFrameCapturer.cs")); + } + + static string Code() + { + var lines = Source().Split('\n'); + var kept = new System.Text.StringBuilder(); + foreach (var l in lines) + { + var t = l.TrimStart(); + if (t.StartsWith("//") || t.StartsWith("///") || t.StartsWith("*")) continue; + kept.AppendLine(l); + } + return kept.ToString(); + } + + [Test] + public void Readback_is_asynchronous_only() + { + var code = Code(); + Assert.That(code, Does.Contain("AsyncGPUReadback.Request(")); + Assert.That(code, Does.Not.Contain("WaitForCompletion")); + Assert.That(code, Does.Not.Contain("ReadPixels")); + Assert.That(code, Does.Not.Contain("CaptureScreenshotAsTexture")); + } + + [Test] + public void Capture_is_off_unless_enabled_and_android_only() + { + var code = Code(); + Assert.That(code, Does.Not.Contain("RuntimeInitializeOnLoadMethod")); + Assert.That(code, Does.Not.Contain("void Awake")); + Assert.That(code, Does.Contain("#if UNITY_ANDROID && !UNITY_EDITOR")); + Assert.That(code, Does.Contain("public static bool Enable(")); + } + + [Test] + public void Every_request_goes_through_the_policy_and_the_push_uses_the_policy_pixel_stride() + { + var code = Code(); + Assert.That(code, Does.Contain("_policy.TryBegin(")); + Assert.That(code, Does.Contain("_policy.Complete(")); + Assert.That(code, Does.Contain("FrameRedaction.BlankRects(")); + Assert.That(code, Does.Contain("_args[5].i = FramePushPolicy.PixelStride;")); + } + + [Test] + public void Push_signature_matches_VideoFrameConsumer_addFrame_7_4_1() + { + Assert.That(Code(), Does.Contain("\"(JILjava/nio/ByteBuffer;IIIL\" + RowOrderClass + \";)V\"")); + Assert.That(Code(), Does.Contain("com/bugsee/library/contracts/capture/consumers/VideoFrameConsumer")); + } + } +} diff --git a/Tools~/scripts/compile-check.sh b/Tools~/scripts/compile-check.sh index d87f2b1..da98cb3 100644 --- a/Tools~/scripts/compile-check.sh +++ b/Tools~/scripts/compile-check.sh @@ -12,10 +12,11 @@ dir="$root/Tools~/CompileCheck" # name|player-only (true = Runtime assembly only, no UnityEditor)|defines configs=' -android-player|true|UNITY_ANDROID -android-player-il2cpp|true|UNITY_ANDROID;ENABLE_IL2CPP +android-player|true|UNITY_ANDROID;BUGSEE_HAS_SCREENCAPTURE +android-player-il2cpp|true|UNITY_ANDROID;ENABLE_IL2CPP;BUGSEE_HAS_SCREENCAPTURE ios-player-il2cpp|true|UNITY_IOS;ENABLE_IL2CPP;UNITY_2023_1_OR_NEWER standalone-player|true|UNITY_STANDALONE_OSX +android-player-no-screencapture|true|UNITY_ANDROID editor-android|false|UNITY_EDITOR;UNITY_ANDROID editor-ios|false|UNITY_EDITOR;UNITY_IOS editor-standalone-il2cpp|false|UNITY_EDITOR;UNITY_STANDALONE_OSX;ENABLE_IL2CPP From a8b42abfd0bd7fbbfef581537f1cdfcdaeb0fddd Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:45:35 +0500 Subject: [PATCH 120/157] Test the Unity 6 export through the real entry points; fix trailing blank line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a default-template fixture set next to the sample-template one, tests for plugin lines and EnsureProjectRepositories, exact strip round trips and region counts. MarkedGradleBlock no longer appends an extra newline to files that end with one. Updates stale 'reconstructed' comments and the changelog. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Runtime/WrapperPolicy/MarkedGradleBlock.cs | 13 +- .../launcher.build.gradle | 81 ++++++++ .../root.build.gradle | 12 ++ .../settings.gradle | 0 .../unityLibrary.build.gradle | 195 ++++++++++++++++++ .../launcher.build.gradle | 0 .../root.build.gradle | 0 .../settings.gradle | 23 +++ .../unityLibrary.build.gradle | 0 Tests~/WrapperPolicy/GradleKotlinDslTests.cs | 9 +- .../UnityGeneratedLayoutTests.cs | 154 +++++++++++--- 12 files changed, 448 insertions(+), 41 deletions(-) create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/launcher.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/root.build.gradle rename Tests~/WrapperPolicy/Fixtures/{Unity6000.0.84f1 => Unity6000.0.84f1-default}/settings.gradle (100%) create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/unityLibrary.build.gradle rename Tests~/WrapperPolicy/Fixtures/{Unity6000.0.84f1 => Unity6000.0.84f1-sample-templates}/launcher.build.gradle (100%) rename Tests~/WrapperPolicy/Fixtures/{Unity6000.0.84f1 => Unity6000.0.84f1-sample-templates}/root.build.gradle (100%) create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/settings.gradle rename Tests~/WrapperPolicy/Fixtures/{Unity6000.0.84f1 => Unity6000.0.84f1-sample-templates}/unityLibrary.build.gradle (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68332de..d484b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. -- Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. +- Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates and by exporting (not building) the Android Gradle project with Unity 6000.0.84f1 (default and custom templates); Unity 2022 is still unverified (reconstructed Groovy fixtures) and no Gradle build was run on either. Injecting the Gradle plugin lines no longer adds an extra blank line at the end of a file that ends with a newline. - iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. - Android `IReport.Attributes` and `IReport.Attachments` returned empty stubs; they now read the native report. Attachment name/file name/MIME type setters are now called with the fluent `Attachment` return type, so they resolve over JNI. - Added `INetworkEvent.IsOverride` (Android: maps `NetworkEvent.isOverride`; iOS: no native equivalent, kept on the managed event only). diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs index 33146c7..7c85a15 100644 --- a/Runtime/WrapperPolicy/MarkedGradleBlock.cs +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -57,7 +57,7 @@ public static string Apply(string existing, string anchorLine, string markedLine if (changed) { - return JoinLines(lines, newline, existing.EndsWith("\n", StringComparison.Ordinal)); + return JoinLines(lines, newline); } int depth = 0; @@ -469,7 +469,7 @@ static void ReplaceLines(List lines, int start, int end, List re lines.InsertRange(start, replacement); } - static string JoinLines(List lines, string newline, bool trailingNewline) + static string JoinLines(List lines, string newline) { var sb = new StringBuilder(); for (int i = 0; i < lines.Count; i++) @@ -479,10 +479,6 @@ static string JoinLines(List lines, string newline, bool trailingNewline { sb.Append(newline); } - else if (trailingNewline) - { - sb.Append(newline); - } } return sb.ToString(); @@ -527,7 +523,6 @@ static string InsertAfterLine( int anchorIndex, string markedLine) { - bool trailingNewline = existing.EndsWith("\n", StringComparison.Ordinal); var sb = new StringBuilder(existing.Length + markedLine.Length + newline.Length + 8); for (int i = 0; i < lines.Count; i++) { @@ -542,10 +537,6 @@ static string InsertAfterLine( { sb.Append(newline); } - else if (trailingNewline) - { - sb.Append(newline); - } } return sb.ToString(); diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/launcher.build.gradle new file mode 100644 index 0000000..15f3645 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/launcher.build.gradle @@ -0,0 +1,81 @@ +apply plugin: 'com.android.application' +apply from: 'setupSymbols.gradle' +apply from: '../shared/keepUnitySymbols.gradle' + +dependencies { + implementation project(':unityLibrary') +} + +android { + namespace "com.bugsee.minigame" + ndkPath "/Applications/Unity/Hub/Editor/6000.0.84f1/PlaybackEngines/AndroidPlayer/NDK" + ndkVersion "27.2.12479018" + compileSdk 36 + buildToolsVersion "36.0.0" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId "com.bugsee.minigame" + versionName "1.0" + minSdk 23 + targetSdk 36 + versionCode 1 + + ndk { + abiFilters "arm64-v8a" + debugSymbolLevel "none" + } + } + + lint { + abortOnError false + } + + androidResources { + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + } + + packaging { + jniLibs { + useLegacyPackaging true + } + } + + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + jniDebuggable true + signingConfig signingConfigs.debug + } + + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + signingConfig signingConfigs.debug + } + } + + bundle { + language { + enableSplit false + } + + density { + enableSplit false + } + + abi { + enableSplit true + } + + texture { + enableSplit true + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/root.build.gradle new file mode 100644 index 0000000..4d9f96b --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/root.build.gradle @@ -0,0 +1,12 @@ +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 '9.0.0' apply false + id 'com.android.library' version '9.0.0' apply false +} + +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/settings.gradle similarity index 100% rename from Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/settings.gradle rename to Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/settings.gradle diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/unityLibrary.build.gradle new file mode 100644 index 0000000..d15c791 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-default/unityLibrary.build.gradle @@ -0,0 +1,195 @@ +apply plugin: 'com.android.library' +apply from: '../shared/keepUnitySymbols.gradle' + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.core:core:1.9.0' + implementation 'androidx.games:games-activity:4.4.2' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.games:games-frame-pacing:1.10.0' +} + +android { + namespace "com.unity3d.player" + ndkPath "/Applications/Unity/Hub/Editor/6000.0.84f1/PlaybackEngines/AndroidPlayer/NDK" + ndkVersion "27.2.12479018" + compileSdk 36 + buildToolsVersion "36.0.0" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + defaultConfig { + consumerProguardFiles "proguard-unity.txt" + minSdk 23 + targetSdk 36 + + ndk { + abiFilters "arm64-v8a" + debugSymbolLevel "none" + } + + externalNativeBuild { + cmake { + arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + } + } + } + + lint { + abortOnError false + } + + androidResources { + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + } + + packaging { + jniLibs { + useLegacyPackaging true + } + } +} + + +android.externalNativeBuild { + cmake { + version "3.22.1" + path "src/main/cpp/CMakeLists.txt" + } +} +android.buildFeatures { + prefab true +} + +android { + tasks.register('buildIl2Cpp') { + def workingDir = projectDir.toString().replaceAll('\\\\', '/'); + def archs = [ + 'arm64' : 'arm64-v8a' + ] + def staticLibs = [ + 'arm64' : [ ] + ] + inputs.files fileTree(dir: 'src/main/Il2CppOutputProject', include: ['**/*']) + inputs.files fileTree(dir: 'src/main/jniStaticLibs', include: ['**/*']) + archs.each { arch, abi -> + outputs.file getIl2CppOutputPath(workingDir, abi) + outputs.file getIl2CppSymbolPath(workingDir, abi) + } + doLast { + archs.each { arch, abi -> + buildIl2CppImpl(workingDir, 'Release', arch, abi, staticLibs[arch] as String[]); + } + } + } + + sourceSets { + main { + jni.srcDirs = ["src/main/Il2CppOutputProject"] + } + } +} + +androidComponents { + onVariants(selector().all()) { variant -> + tasks.configureEach { task -> + def mergeTaskName = "merge${variant.name.capitalize()}JniLibFolders"; + if (task.name == mergeTaskName) { + dependsOn buildIl2Cpp + } + } + } +} + +def getSdkDir() { + Properties local = new Properties() + local.load(new FileInputStream("${rootDir}/local.properties")) + return local.getProperty('sdk.dir') +} + +def getIl2CppOutputPath(String workingDir, String abi) { + return "${workingDir}/src/main/jniLibs/${abi}/libil2cpp.so"; +} + +def getIl2CppSymbolPath(String workingDir, String abi) { + return "${workingDir}/symbols/${abi}/libil2cpp.so"; +} + +def buildIl2CppImpl(String workingDir, String configuration, String architecture, String abi, String[] staticLibraries) { + def commandLineArgs = [] + commandLineArgs.add("--compile-cpp") + commandLineArgs.add("--platform=Android") + commandLineArgs.add("--architecture=${architecture}") + commandLineArgs.add("--outputpath=${workingDir}/src/main/jniLibs/${abi}/libil2cpp.so") + commandLineArgs.add("--baselib-directory=${workingDir}/src/main/jniStaticLibs/${abi}") + commandLineArgs.add("--incremental-g-c-time-slice=3") + commandLineArgs.add("--configuration=${configuration}") + commandLineArgs.add("--dotnetprofile=unityaot-linux") + commandLineArgs.add("--usymtool-path=${workingDir}/src/main/Il2CppOutputProject/usymtool") + commandLineArgs.add("--profiler-report") + commandLineArgs.add("--profiler-output-file=${workingDir}/build/il2cpp_${abi}_${configuration}/il2cpp_conv.traceevents") + commandLineArgs.add("--print-command-line") + commandLineArgs.add("--static-lib-il2-cpp") + commandLineArgs.add("--data-folder=${workingDir}/src/main/Il2CppOutputProject/Source/il2cppOutput/data") + commandLineArgs.add("--generatedcppdir=${workingDir}/src/main/Il2CppOutputProject/Source/il2cppOutput") + commandLineArgs.add("--cachedirectory=${workingDir}/build/il2cpp_${abi}_${configuration}/il2cpp_cache") + commandLineArgs.add("--tool-chain-path=" + getProperty("unity.androidNdkPath")) + + staticLibraries.each { fileName -> + commandLineArgs.add("--additional-libraries=${workingDir}/src/main/jniStaticLibs/${abi}/${fileName}") + } + + def executableExtension = "" + if (org.gradle.internal.os.OperatingSystem.current().isWindows()) { + executableExtension = ".exe" + commandLineArgs = commandLineArgs*.replace('\"', '\\\"') + } + + def command = "${workingDir}/src/main/Il2CppOutputProject/IL2CPP/build/deploy/il2cpp${executableExtension}"; + execCommand(workingDir, [command, *commandLineArgs], [ + "ANDROID_SDK_ROOT": getProperty("unity.androidSdkPath"), + "ANDROID_NDK_ROOT": getProperty("unity.androidNdkPath"), + "NDK_ROOT": getProperty("unity.androidNdkPath"), + "ANDROID_NDK_HOME": getProperty("unity.androidNdkPath"), + ]) + + def dbgLevel = project.android.defaultConfig.ndk.debugSymbolLevel; + def usePublicSymbols = dbgLevel == null || !dbgLevel.toString().toLowerCase().equals("full") + def extensionToRemove = usePublicSymbols ? ".dbg.so" : ".sym.so" + def extensionToKeep = usePublicSymbols ? ".sym.so" : ".dbg.so" + + delete "${workingDir}/src/main/jniLibs/${abi}/libil2cpp${extensionToRemove}" + ant.move(file: "${workingDir}/src/main/jniLibs/${abi}/libil2cpp${extensionToKeep}", tofile: "${workingDir}/symbols/${abi}/libil2cpp.so") + +} + +def execCommand(String workDir, List command, Map env = [:]) { + def execOutput = providers.exec { + workingDir workDir + commandLine command + environment env + ignoreExitValue true + } + + def exitValue = execOutput.result.get().getExitValue() + def stdout = execOutput.standardOutput.asText.get() + def stderr = execOutput.standardError.asText.get() + + println "$stdout\n$stderr" + if (exitValue != 0) { + def error = new StringBuilder() + error.append("Command execution failed with exit code: $exitValue\n"); + error.append("Working Directory: ${workDir}\n"); + error.append("Environment Variables:\n"); + env.each { key, value -> + error.append(" - ${key} = ${value}\n") + } + error.append("Command Line:\n${command.join(' ')}\n"); + throw new GradleException(error.toString()) + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/launcher.build.gradle similarity index 100% rename from Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/launcher.build.gradle rename to Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/launcher.build.gradle diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/root.build.gradle similarity index 100% rename from Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/root.build.gradle rename to Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/root.build.gradle diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/settings.gradle new file mode 100644 index 0000000..1e121f1 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +include ':launcher' +include ':unityLibrary' + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + + repositories { + google() + mavenCentral() + + flatDir { + dirs "${project(':unityLibrary').projectDir}/libs" + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/unityLibrary.build.gradle similarity index 100% rename from Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1/unityLibrary.build.gradle rename to Tests~/WrapperPolicy/Fixtures/Unity6000.0.84f1-sample-templates/unityLibrary.build.gradle diff --git a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs index 1290211..f74bfc9 100644 --- a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs +++ b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs @@ -7,7 +7,8 @@ namespace Bugsee.WrapperPolicy.Tests /// /// Fixture provenance: the 2021.3 settings fixture is the settingsTemplate.gradle shipped in Unity /// 2021.3.45f1 (PlaybackEngines/AndroidPlayer/Tools/GradleTemplates) with its placeholders emptied and mavenCentral() dropped from the project repositories (so there is something to insert). The - /// Kotlin, 2022 and Unity 6 fixtures are RECONSTRUCTED (no 2022/6 editor was available): Unity 2022.2+ and + /// Kotlin and 2022 fixtures are RECONSTRUCTED (no 2022 editor could be installed; Unity 6000.0.84f1 exports are + /// checked in under Fixtures and covered by UnityGeneratedLayoutTests): Unity 2022.2+ and /// Unity 6 keep the same Groovy settings shape (vendor docs show PREFER_SETTINGS + google/mavenCentral/flatDir), /// and Unity generates no .kts files itself, so the .kts fixtures model a project that converted them. /// @@ -45,9 +46,9 @@ public class GradleKotlinDslTests " }\n" + "}\n"; - // RECONSTRUCTED, not generated by Unity 6: the 2021.3.45 baseProjectTemplate.gradle with the AGP version - // changed. Whether Unity 6's template differs beyond that could not be confirmed (docs describe the files' - // roles, not their contents). + // RECONSTRUCTED, not generated by Unity: the 2021.3.45 baseProjectTemplate.gradle with the AGP version + // changed (8.3.0, task clean). Unity 6000.0.84f1's real default differs (AGP 9.0.0, tasks.register('clean')): + // see Fixtures/Unity6000.0.84f1-default/root.build.gradle. const string ReconstructedRootBuild = "plugins {\n" + " id 'com.android.application' version '8.3.0' apply false\n" + diff --git a/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs b/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs index 536af29..36dbce2 100644 --- a/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs +++ b/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs @@ -1,48 +1,152 @@ using System.IO; +using System.Text.RegularExpressions; using NUnit.Framework; namespace Bugsee.WrapperPolicy.Tests { /// - /// The injector over Gradle files Unity actually generated. Fixtures/Unity6000.0.84f1 is an exported (not built) - /// Android Gradle project of the sample from the real Unity 6000.0.84f1 editor with default Gradle templates, - /// minus Bugsee's own edits. No Unity 2022 LTS export is checked in: that editor could not be installed (#48). + /// The injector over Gradle files a real Unity 6000.0.84f1 editor generated (Android Gradle project exported, + /// not built), minus Bugsee's own edits. Two fixture sets: + /// + /// Unity6000.0.84f1-default: a bare project with no custom Gradle templates (AGP 9.0.0, Java 17 launcher). + /// Unity6000.0.84f1-sample-templates: the BugseeMiniGame sample, whose root and launcher files come from + /// the CUSTOM base/launcher templates the package writes into Assets/Plugins/Android (AGP 7.4.2, Java 11). + /// + /// No Unity 2022 LTS export is checked in: that editor could not be installed (#48). /// public class UnityGeneratedLayoutTests { - const string Dir = "Fixtures/Unity6000.0.84f1/"; + static readonly string[] Sets = { "Unity6000.0.84f1-default", "Unity6000.0.84f1-sample-templates" }; static readonly string[] Artifacts = { "bugsee-android", "bugsee-android-ndk" }; - static string Read(string name) + // The anchors and payloads BugseeAndroidGradleSetup passes to MarkedGradleBlock.Apply. + const string RootAnchor = "plugins {"; + const string RootLine = " id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin"; + const string LauncherAnchor = "apply plugin: 'com.android.application'"; + const string LauncherPatch = + "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + + "bugsee {\n" + + " // App token is supplied at runtime via Bugsee.Launch.\n" + + " ndk {\n" + + " enabled = true\n" + + " }\n" + + "} // bugsee:gradle-ndk"; + + static string Read(string set, string name) + { + return File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", set, name)); + } + + static int Count(string text, string needle) + { + return Regex.Matches(text, Regex.Escape(needle)).Count; + } + + static GradleMavenInjection.DependencyResult Apply(string gradle) + { + return GradleMavenInjection.ApplyDependencies(gradle, "com.bugsee", Artifacts, "7.4.1"); + } + + [TestCaseSource(nameof(Sets))] + public void Library_dependencies_land_in_the_top_level_block(string set) + { + string result = Apply(Read(set, "unityLibrary.build.gradle")).Text; + + int begin = result.IndexOf("// bugsee:maven-dependencies begin", System.StringComparison.Ordinal); + Assert.That(begin, Is.GreaterThan(0)); + Assert.That(result.Substring(0, begin).TrimEnd().EndsWith("dependencies {"), Is.True, "first entry of dependencies"); + Assert.That(result, Does.Contain(" implementation 'com.bugsee:bugsee-android:7.4.1'")); + Assert.That(result, Does.Contain(" implementation 'com.bugsee:bugsee-android-ndk:7.4.1'")); + } + + [TestCaseSource(nameof(Sets))] + public void Removing_the_injected_region_gives_back_the_original_text(string set) + { + string raw = Read(set, "unityLibrary.build.gradle"); + string result = Apply(raw).Text; + + string stripped = Regex.Replace( + result, @"[ \t]*// bugsee:maven-dependencies begin.*?// bugsee:maven-dependencies end\r?\n", string.Empty, RegexOptions.Singleline); + + Assert.That(stripped, Is.EqualTo(raw)); + } + + [TestCaseSource(nameof(Sets))] + public void A_second_run_changes_nothing_and_keeps_one_region(string set) + { + string once = Apply(Read(set, "unityLibrary.build.gradle")).Text; + string twice = Apply(once).Text; + + Assert.That(twice, Is.EqualTo(once)); + Assert.That(Count(twice, "bugsee:maven-dependencies begin"), Is.EqualTo(1)); + Assert.That(Count(twice, "bugsee:maven-dependencies end"), Is.EqualTo(1)); + Assert.That(Count(twice, "bugsee-android:7.4.1"), Is.EqualTo(1)); + } + + [TestCaseSource(nameof(Sets))] + public void Settings_already_resolve_from_maven_central_so_nothing_is_added(string set) + { + string settings = Read(set, "settings.gradle"); + string root = Read(set, "root.build.gradle"); + + var result = GradleMavenInjection.EnsureProjectRepositories(settings, root); + + Assert.That(result.Mode, Is.EqualTo(GradleMavenInjection.RepositoriesMode.PreferSettings)); + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); + Assert.That(result.Found, Is.True); + Assert.That(result.Settings, Is.EqualTo(settings)); + Assert.That(result.RootBuild, Is.EqualTo(root), "PREFER_SETTINGS: project repositories are ignored"); + } + + [TestCaseSource(nameof(Sets))] + public void Without_maven_central_in_settings_it_is_inserted_in_settings_only(string set) { - return File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, Dir, name)); + string settings = Read(set, "settings.gradle"); + string withoutCentral = Regex.Replace( + settings, @"(dependencyResolutionManagement.*?)\n\s*mavenCentral\(\)", "$1", RegexOptions.Singleline); + Assert.That(withoutCentral, Is.Not.EqualTo(settings), "fixture sanity"); + string root = Read(set, "root.build.gradle"); + + var result = GradleMavenInjection.EnsureProjectRepositories(withoutCentral, root); + + Assert.That(result.SettingsStatus, Is.EqualTo(GradleMavenInjection.RepositoryStatus.Inserted)); + Assert.That(Count(result.Settings, "bugsee:maven-repository"), Is.EqualTo(1)); + Assert.That(result.RootBuild, Is.EqualTo(root)); } - [Test] - public void Library_dependencies_land_in_the_top_level_block_and_nothing_else_changes() + [TestCaseSource(nameof(Sets))] + public void Gradle_plugin_lines_go_into_the_root_plugins_block_once(string set) { - string raw = Read("unityLibrary.build.gradle"); - var result = GradleMavenInjection.ApplyDependencies(raw, "com.bugsee", Artifacts, "7.4.1"); - var again = GradleMavenInjection.ApplyDependencies(result.Text, "com.bugsee", Artifacts, "7.4.1"); + string raw = Read(set, "root.build.gradle"); - Assert.That(result.Text, Does.Contain(" implementation 'com.bugsee:bugsee-android:7.4.1'")); - Assert.That(result.Text, Does.Contain(" implementation 'com.bugsee:bugsee-android-ndk:7.4.1'")); - Assert.That(again.Text, Is.EqualTo(result.Text)); - int begin = result.Text.IndexOf("bugsee:maven-dependencies begin", System.StringComparison.Ordinal); - Assert.That(result.Text.LastIndexOf("dependencies {", begin, System.StringComparison.Ordinal), Is.GreaterThanOrEqualTo(0)); - Assert.That(result.Text.Substring(0, begin).Contains("android {"), Is.False, "before the android block"); + string once = MarkedGradleBlock.Apply(raw, RootAnchor, RootLine); + string twice = MarkedGradleBlock.Apply(once, RootAnchor, RootLine); + + Assert.That(once, Is.Not.EqualTo(raw)); + Assert.That(twice, Is.EqualTo(once)); + Assert.That(Count(once, "bugsee:gradle-plugin"), Is.EqualTo(1)); + int plugins = once.IndexOf("plugins {", System.StringComparison.Ordinal); + int line = once.IndexOf("com.bugsee.android.gradle", System.StringComparison.Ordinal); + int close = once.IndexOf("\n}", plugins, System.StringComparison.Ordinal); + Assert.That(line, Is.InRange(plugins, close)); + Assert.That(once.Replace(RootLine + "\n", string.Empty), Is.EqualTo(raw)); } - [Test] - public void Settings_already_resolve_from_maven_central_so_nothing_is_added() + [TestCaseSource(nameof(Sets))] + public void Gradle_plugin_and_ndk_block_follow_the_launcher_application_plugin_once(string set) { - string settings = Read("settings.gradle"); + string raw = Read(set, "launcher.build.gradle"); + + string once = MarkedGradleBlock.Apply(raw, LauncherAnchor, LauncherPatch); + string twice = MarkedGradleBlock.Apply(once, LauncherAnchor, LauncherPatch); - Assert.That(GradleMavenInjection.GetRepositoriesMode(settings), - Is.EqualTo(GradleMavenInjection.RepositoriesMode.PreferSettings)); - var status = GradleMavenInjection.EnsureMavenCentral(settings, "dependencyResolutionManagement", out string updated); - Assert.That(status, Is.EqualTo(GradleMavenInjection.RepositoryStatus.AlreadyPresent)); - Assert.That(updated, Is.EqualTo(settings)); + Assert.That(twice, Is.EqualTo(once)); + Assert.That(Count(once, "bugsee:gradle-plugin"), Is.EqualTo(1)); + Assert.That(Count(once, "bugsee:gradle-ndk"), Is.EqualTo(1)); + Assert.That(once.IndexOf("com.bugsee.android.gradle", System.StringComparison.Ordinal), + Is.GreaterThan(once.IndexOf(LauncherAnchor, System.StringComparison.Ordinal))); + Assert.That(once.IndexOf("bugsee {", System.StringComparison.Ordinal), + Is.LessThan(once.IndexOf("android {", System.StringComparison.Ordinal))); } } } From 7c34b0d912bbab8d15ba7162f8c216f341f90848 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 13:58:38 +0500 Subject: [PATCH 121/157] Frame pusher review fixes: measured row order, fail-closed masking, native-pixel rects, SDK clock. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row order measured on the emulator (OpenGLES3 is bottom-up); unmeasured APIs blank the whole frame while a secure rect is registered. Rects use the activity window size taken at request time, the registry is locked and read without allocating, timestamps use TimeUtils.monotonicWallTimeMillis, consumer fetch retries, Disable hops to the main thread, JNI refs and exceptions are cleaned up. Source-grep tests replaced by behaviour tests. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 9 +- Runtime/Capture/BugseeFrameCapturer.cs | 215 +++++++++++++++--- Runtime/WrapperPolicy/FramePushPolicy.cs | 32 ++- Runtime/WrapperPolicy/FrameRedaction.cs | 71 +++--- Runtime/WrapperPolicy/SecureRectRegistry.cs | 117 ++++++---- .../BugseeHarness/BugseeSampleBootstrap.cs | 9 +- .../OrientationPatternOverlay.cs | 31 +++ .../OrientationPatternOverlay.cs.meta | 11 + Tests~/WrapperPolicy/FramePushPolicyTests.cs | 139 +++++------ .../WrapperPolicy/SecureRectRegistryTests.cs | 29 +++ 11 files changed, 473 insertions(+), 192 deletions(-) create mode 100644 Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs create mode 100644 Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 77caed0..dd7c858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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`, reused buffers, one request in flight, frames dropped meanwhile, throttled to `TargetFps`, default 5). It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (the SDK does not mask them); blackout is honoured by the SDK. Readback failures turn it off (no synchronous fallback), and Vulkan needs Unity 2021.3.18 or newer. **Not benchmarked on devices, and the readback row order per graphics API is unverified** (`BugseeFrameCapturerOptions.RowOrder` overrides it). Needs the `com.unity.modules.screencapture` module (compiled out without it). Nothing changes unless you call `Enable()`: the default capture stays the SDK's own (`VideoMode.V2`), and a launch with `DirectBuffers` but no `Enable()` gets no frames from Unity (nothing pushes them). Refs #22 (benchmarking V2 against this on real devices is outstanding). +- 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`, reused buffers, one request in flight, frames dropped meanwhile, throttled to `TargetFps`, default 5). It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (the SDK does not mask them); blackout is honoured by the SDK. Readback failures turn it off (no synchronous fallback), and Vulkan needs Unity 2021.3.18 or newer. **Not benchmarked on devices.** The readback row order was measured for OpenGLES3 only; on any other graphics API it is unverified and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Pixelation and secure views are not applied to pushed frames. Needs the `com.unity.modules.screencapture` module (compiled out without it). Nothing changes unless you call `Enable()`: the default capture stays the SDK's own (`VideoMode.V2`), and a launch with `DirectBuffers` but no `Enable()` gets no frames from Unity (nothing pushes them). Refs #22 (benchmarking V2 against this on real 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index e6b0754..686fb9b 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -248,9 +248,10 @@ What to know before using it: - Needs the Screen Capture built-in module (`com.unity.modules.screencapture`, on by default in new projects); without it the capturer is compiled out and `Enable()` returns false. Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. - It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. -- Redaction: the SDK does not mask frames pushed this way. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle` (screen pixels, top-left origin). Secure native views are not known to it. Blackout is honoured by the SDK. +- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (the activity window size, top-left origin) and scaled to the frame. Secure native views are not known to it. Blackout is honoured by the SDK. +- Row order of the readback was measured only for OpenGLES3 (Android 16 arm64 emulator, Unity 2021.3.45: the first buffer row is the screen bottom, so BottomUp). For any other graphics API (Vulkan, OpenGLES2) it is unverified, and the pusher fails closed: while a secure rectangle is registered, every pushed frame is fully black, and a warning is logged. Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. Without a secure rectangle an unverified order can only show the video upside down. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (crash in older builds). After 5 failed readbacks in a row it turns itself off. -- Row order of the readback is unverified per graphics API. It defaults to top-down when `SystemInfo.graphicsUVStartsAtTop`, bottom-up otherwise; if video comes out upside down, set `BugseeFrameCapturerOptions.RowOrder`. -- Unity cannot hand native memory to Java as a `ByteBuffer`, so each pushed frame costs one Java `byte[]` (about 0.7 MB at 640 px) plus a copy into a reused direct buffer. Lower `MaxDimension` or `TargetFps` to reduce it. The secure-rectangle snapshot also allocates a few ints per frame. +- Unity 2021.3's `AndroidJNI` cannot expose native memory as a `ByteBuffer` (later Unity versions can; not used here), so each pushed frame costs one Java `byte[]` (about 0.7 MB at 640 px) plus a copy into a reused direct buffer. Lower `MaxDimension` or `TargetFps` to reduce it. Secure rectangles are copied into a reused buffer under a lock, so that part does not allocate per frame. +- Frames are stamped with the SDK's own clock (`TimeUtils.monotonicWallTimeMillis`, falling back to boot time plus `SystemClock.elapsedRealtime`). - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. -- Exercised only on an Android 16 arm64 emulator (IL2CPP development build): frames were pushed and the SDK wrote video exports. Video orientation and content were not inspected, and there are no device or performance numbers. +- Exercised only on an Android 16 arm64 emulator (IL2CPP development build, OpenGLES3): frames were pushed and the SDK wrote video exports. Video content was not inspected, and there are no device or performance numbers. diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index d34a9cf..fda6b19 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -23,10 +23,14 @@ public sealed class BugseeFrameCapturerOptions public int MaxDimension = 640; /// - /// Row order of the readback buffer. Null picks it from the graphics API (see FramePushPolicy.RowOrderFor). - /// Set it if recorded video comes out upside down. + /// Row order of the readback buffer. Null uses the order measured for the running graphics API; for an API + /// without a measurement the pusher is fail-closed (see FramePushPolicy.ResolveRowOrder). Setting it declares + /// the order verified, so check it with first. /// public VideoFrameRowOrder? RowOrder; + + /// Log the colours of the first and last readback rows (and the graphics API) for the first few frames, to check orientation. + public bool LogOrientationProbe; } /// Counters of the experimental frame pusher. @@ -96,6 +100,13 @@ public static bool Enable(BugseeFrameCapturerOptions options = null) /// Stops the pusher and releases its buffers once any push in flight has finished. public static void Disable() { +#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE + if (!Internal.MainThreadDispatcher.IsMainThread) + { + Internal.MainThreadDispatcher.Run(Disable); + return; + } +#endif if (_instance == null) return; _instance.Stop(); } @@ -118,27 +129,43 @@ BugseeFrameCapturerStats ReadStats() const string ConsumerClass = "com/bugsee/library/contracts/capture/consumers/VideoFrameConsumer"; const string RowOrderClass = ConsumerClass + "$RowOrder"; const string AddFrameSignature = "(JILjava/nio/ByteBuffer;IIIL" + RowOrderClass + ";)V"; + const string TimeUtilsClass = "com/bugsee/library/shared/util/TimeUtils"; const float StatsLogIntervalSeconds = 10f; + const double ConsumerRetrySeconds = 2.0; + const double ConsumerWarnSeconds = 30.0; + const int ProbeFrames = 3; static readonly WaitForEndOfFrame EndOfFrame = new WaitForEndOfFrame(); BugseeFrameCapturerOptions _options; VideoFrameRowOrder _rowOrder; + bool _rowOrderVerified; Action _onReadback; RenderTexture _fullRt, _smallRt; int _captureW, _captureH; long _requestTimestampMs; + // Size of the screen at request time, in native window pixels (what secure rectangles are registered in). + int _lastScreenW, _lastScreenH, _nativeW, _nativeH, _requestNativeW, _requestNativeH; + int[] _rects; + int _probeFrames; + // Pixels: the readback callback (main thread) fills _pixels, the push thread copies them into the Java - // direct buffer and calls addFrame. Unity's AndroidJNI cannot wrap or address native memory as a ByteBuffer, - // so the copy goes through a Java byte[] (one Java-heap array per pushed frame, no managed allocation). Only one request is in flight, so neither side overwrites the other. + // direct buffer and calls addFrame. Unity 2021.3's AndroidJNI cannot wrap or address native memory as a + // ByteBuffer (later Unity versions can; not used here), so the copy goes through a Java byte[] (one Java-heap + // array per pushed frame, no managed allocation). Only one request is in flight, so neither side overwrites + // the other. byte[] _pixels; + readonly object _nativeLock = new object(); IntPtr _directBuffer; // global ref to a reused direct java.nio.ByteBuffer IntPtr _bufferClear, _bufferRewind, _bufferPut; // method ids on java.nio.Buffer / ByteBuffer IntPtr _consumer; // global ref to the VideoFrameConsumer IntPtr _addFrameMethod; IntPtr _topDown, _bottomUp; // global refs to the RowOrder constants + IntPtr _timeClass, _timeMethod; // TimeUtils.monotonicWallTimeMillis(), the SDK's own clock + long _clockOffsetMs; // fallback when TimeUtils is not found: wall clock minus SystemClock.elapsedRealtime + IntPtr _elapsedClass, _elapsedMethod; readonly jvalue[] _args = new jvalue[7]; readonly jvalue[] _putArgs = new jvalue[1]; readonly jvalue[] _noArgs = new jvalue[0]; @@ -147,6 +174,7 @@ BugseeFrameCapturerStats ReadStats() bool _paused; bool _releaseDone; float _nextStatsLog; + double _nextConsumerTry, _nextConsumerWarn; Thread _worker; readonly SemaphoreSlim _signal = new SemaphoreSlim(0); @@ -158,14 +186,17 @@ bool Begin(BugseeFrameCapturerOptions options) { _options = options; _policy = new FramePushPolicy(options.TargetFps); - _rowOrder = FramePushPolicy.RowOrderFor(SystemInfo.graphicsUVStartsAtTop, options.RowOrder); + _rowOrder = FramePushPolicy.ResolveRowOrder(SystemInfo.graphicsDeviceType.ToString(), options.RowOrder, out _rowOrderVerified); + if (!_rowOrderVerified) + Debug.LogWarning("[BugseeFrameCapturer] readback row order is unverified for " + SystemInfo.graphicsDeviceType + + ": while a secure rectangle is registered, pushed frames are fully blacked out. Set BugseeFrameCapturerOptions.RowOrder after checking it with LogOrientationProbe."); _onReadback = OnReadback; try { _topDown = RowOrderConstant("TOP_DOWN"); _bottomUp = RowOrderConstant("BOTTOM_UP"); - IntPtr cls = AndroidJNI.FindClass(ConsumerClass); - _addFrameMethod = AndroidJNI.GetMethodID(cls, "addFrame", AddFrameSignature); + IntPtr cls = FindClassOrClear(ConsumerClass); + _addFrameMethod = cls == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.GetMethodID(cls, "addFrame", AddFrameSignature); AndroidJNI.DeleteLocalRef(cls); IntPtr bufferCls = AndroidJNI.FindClass("java/nio/Buffer"); _bufferClear = AndroidJNI.GetMethodID(bufferCls, "clear", "()Ljava/nio/Buffer;"); @@ -177,11 +208,13 @@ bool Begin(BugseeFrameCapturerOptions options) if (_bufferClear == IntPtr.Zero || _bufferRewind == IntPtr.Zero || _bufferPut == IntPtr.Zero || _addFrameMethod == IntPtr.Zero || _topDown == IntPtr.Zero || _bottomUp == IntPtr.Zero) { + // On an SDK older than 7.4.1 the lookups above leave a pending NoSuchMethodError/ClassNotFoundException. AndroidJNI.ExceptionClear(); Debug.LogWarning("[BugseeFrameCapturer] VideoFrameConsumer.addFrame not found; this needs Bugsee Android 7.4.1 or newer."); ReleaseNative(); return false; } + InitClock(); } catch (Exception ex) { @@ -197,17 +230,54 @@ bool Begin(BugseeFrameCapturerOptions options) return true; } + // JNI FindClass leaves a pending exception when the class is missing; clear it so later calls are legal. + static IntPtr FindClassOrClear(string name) + { + IntPtr cls = AndroidJNI.FindClass(name); + if (cls == IntPtr.Zero) AndroidJNI.ExceptionClear(); + return cls; + } + static IntPtr RowOrderConstant(string name) { - IntPtr cls = AndroidJNI.FindClass(RowOrderClass); + IntPtr cls = FindClassOrClear(RowOrderClass); + if (cls == IntPtr.Zero) return IntPtr.Zero; IntPtr field = AndroidJNI.GetStaticFieldID(cls, name, "L" + RowOrderClass + ";"); - IntPtr local = AndroidJNI.GetStaticObjectField(cls, field); + IntPtr local = field == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.GetStaticObjectField(cls, field); IntPtr global = local == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.NewGlobalRef(local); AndroidJNI.DeleteLocalRef(local); AndroidJNI.DeleteLocalRef(cls); return global; } + // The SDK stamps frames with TimeUtils.monotonicWallTimeMillis() (boot time + elapsedRealtime); a wall-clock + // timestamp could jump relative to that and make the SDK drop or reorder frames. + void InitClock() + { + IntPtr cls = FindClassOrClear(TimeUtilsClass); + IntPtr method = cls == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.GetStaticMethodID(cls, "monotonicWallTimeMillis", "()J"); + if (method != IntPtr.Zero) + { + _timeClass = AndroidJNI.NewGlobalRef(cls); + _timeMethod = method; + } + else + { + AndroidJNI.ExceptionClear(); + IntPtr clock = AndroidJNI.FindClass("android/os/SystemClock"); + _elapsedClass = AndroidJNI.NewGlobalRef(clock); + _elapsedMethod = AndroidJNI.GetStaticMethodID(clock, "elapsedRealtime", "()J"); + AndroidJNI.DeleteLocalRef(clock); + _clockOffsetMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + - AndroidJNI.CallStaticLongMethod(_elapsedClass, _elapsedMethod, _noArgs); + } + AndroidJNI.DeleteLocalRef(cls); + } + + long NowMs() => _timeMethod != IntPtr.Zero + ? AndroidJNI.CallStaticLongMethod(_timeClass, _timeMethod, _noArgs) + : _clockOffsetMs + AndroidJNI.CallStaticLongMethod(_elapsedClass, _elapsedMethod, _noArgs); + void Stop() { _enabled = false; @@ -227,7 +297,7 @@ void Update() var s = ReadStats(); Debug.Log("[BugseeFrameCapturer] requested=" + s.Requested + " pushed=" + s.Pushed + " failed=" + s.Failed + " droppedInFlight=" + s.DroppedInFlight + " droppedBlackoutOrStopped=" + s.DroppedBlackoutOrStopped - + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder); + + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder + (_rowOrderVerified ? "" : " (unverified)")); } if (_policy.IsDisabled) _enabled = false; return; @@ -268,7 +338,16 @@ void Tick() // JNI calls only once per due frame, not on every game frame. bool launched = Bugsee.IsLaunched; bool stopped = !launched || Bugsee.IsBlackout; - if (launched && !_wasLaunched && !_policy.HasInFlight) RefreshConsumer(); + if (launched && (!_wasLaunched || _consumer == IntPtr.Zero) && now >= _nextConsumerTry && !_policy.HasInFlight) + { + _nextConsumerTry = now + ConsumerRetrySeconds; + RefreshConsumer(); + if (_consumer == IntPtr.Zero && now >= _nextConsumerWarn) + { + _nextConsumerWarn = now + ConsumerWarnSeconds; + Debug.LogWarning("[BugseeFrameCapturer] the SDK has no video frame consumer yet (not launched with video capture?); retrying."); + } + } _wasLaunched = launched; stopped |= _consumer == IntPtr.Zero; @@ -276,8 +355,11 @@ void Tick() try { + UpdateNativeSize(); if (!EnsureBuffers()) { _policy.Complete(false); return; } - _requestTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _requestTimestampMs = NowMs(); + _requestNativeW = _nativeW; + _requestNativeH = _nativeH; ScreenCapture.CaptureScreenshotIntoRenderTexture(_fullRt); Graphics.Blit(_fullRt, _smallRt); AsyncGPUReadback.Request(_smallRt, 0, TextureFormat.RGBA32, _onReadback); @@ -289,6 +371,33 @@ void Tick() } } + // Secure rectangles are native window pixels, which differ from Screen.width/height when Unity renders at a + // reduced resolution. Re-read the activity window size only when the Unity screen size changes. + void UpdateNativeSize() + { + if (Screen.width == _lastScreenW && Screen.height == _lastScreenH && _nativeW > 0) return; + _lastScreenW = Screen.width; + _lastScreenH = Screen.height; + int w = 0, h = 0; + try + { + using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = player.GetStatic("currentActivity")) + using (var window = activity.Call("getWindow")) + using (var decor = window.Call("getDecorView")) + { + w = decor.Call("getWidth"); + h = decor.Call("getHeight"); + } + } + catch (Exception) + { + // fall back to the Unity size below + } + _nativeW = w > 0 ? w : Screen.width; + _nativeH = h > 0 ? h : Screen.height; + } + void RefreshConsumer() { if (_consumer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_consumer); _consumer = IntPtr.Zero; } @@ -331,8 +440,16 @@ bool EnsureBuffers() arg[0].i = bytes; IntPtr local = AndroidJNI.CallStaticObjectMethod(cls, alloc, arg); AndroidJNI.DeleteLocalRef(cls); - if (local == IntPtr.Zero) { AndroidJNI.ExceptionClear(); return false; } - _directBuffer = AndroidJNI.NewGlobalRef(local); + if (local == IntPtr.Zero) + { + AndroidJNI.ExceptionClear(); + _pixels = null; // so the next request retries the whole setup + return false; + } + lock (_nativeLock) + { + _directBuffer = AndroidJNI.NewGlobalRef(local); + } AndroidJNI.DeleteLocalRef(local); return true; } @@ -348,9 +465,18 @@ void OnReadback(AsyncGPUReadbackRequest request) if (data.Length < _pixels.Length) { _policy.Complete(false); return; } NativeArray.Copy(data, _pixels, _pixels.Length); - // The SDK does not mask pushed frames: blank the registered secure rectangles first. - int[] rects = SecureRectRegistry.Instance.Snapshot(0, 1f); - FrameRedaction.BlankRects(_pixels, _captureW, _captureH, _rowOrder, rects, Screen.width, Screen.height); + if (_options.LogOrientationProbe && _probeFrames < ProbeFrames) LogProbe(); + + // The SDK does not mask pushed frames: blank the registered secure rectangles first. If the row order is + // unverified the mask could land on mirrored rows and leak the real area, so blank the whole frame instead. + int count = SecureRectRegistry.Instance.CopyRects(0, ref _rects); + if (count > 0) + { + if (_rowOrderVerified) + FrameRedaction.BlankRects(_pixels, _captureW, _captureH, _rowOrder, _rects, count, _requestNativeW, _requestNativeH); + else + FrameRedaction.BlankAll(_pixels, _captureW, _captureH); + } _pushW = _captureW; _pushH = _captureH; @@ -358,6 +484,19 @@ void OnReadback(AsyncGPUReadbackRequest request) _signal.Release(); } + // Colour of the centre pixel of the first and last buffer rows, to tell which row is the screen's top. + void LogProbe() + { + _probeFrames++; + int x = _captureW / 2; + int last = _captureH - 1; + int a = (0 * _captureW + x) * 4; + int b = (last * _captureW + x) * 4; + Debug.Log("[BugseeFrameCapturer] probe api=" + SystemInfo.graphicsDeviceType + " uvStartsAtTop=" + SystemInfo.graphicsUVStartsAtTop + + " firstRow rgba=" + _pixels[a] + "," + _pixels[a + 1] + "," + _pixels[a + 2] + "," + _pixels[a + 3] + + " lastRow rgba=" + _pixels[b] + "," + _pixels[b + 1] + "," + _pixels[b + 2] + "," + _pixels[b + 3]); + } + // Push thread: one frame per signal. addFrame converts synchronously on this thread, so the direct buffer can // be reused as soon as it returns. void PushLoop() @@ -388,11 +527,7 @@ bool PushOne() _putArgs[0].l = java; AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferClear, _noArgs)); AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferPut, _putArgs)); - if (AndroidJNI.ExceptionOccurred() != IntPtr.Zero) - { - AndroidJNI.ExceptionClear(); - return false; - } + if (ClearPendingException()) return false; AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferRewind, _noArgs)); } finally @@ -407,12 +542,7 @@ bool PushOne() _args[5].i = FramePushPolicy.PixelStride; _args[6].l = _pushOrder == VideoFrameRowOrder.BottomUp ? _bottomUp : _topDown; AndroidJNI.CallVoidMethod(_consumer, _addFrameMethod, _args); - if (AndroidJNI.ExceptionOccurred() != IntPtr.Zero) - { - AndroidJNI.ExceptionClear(); - return false; - } - return true; + return !ClearPendingException(); } catch (Exception) { @@ -420,6 +550,16 @@ bool PushOne() } } + // ExceptionOccurred returns a local reference: free it, then clear the exception. + static bool ClearPendingException() + { + IntPtr exception = AndroidJNI.ExceptionOccurred(); + if (exception == IntPtr.Zero) return false; + AndroidJNI.DeleteLocalRef(exception); + AndroidJNI.ExceptionClear(); + return true; + } + void ReleaseRenderTextures() { if (_fullRt != null) { _fullRt.Release(); Destroy(_fullRt); _fullRt = null; } @@ -428,15 +568,23 @@ void ReleaseRenderTextures() void ReleaseDirectBuffer() { - if (_directBuffer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_directBuffer); _directBuffer = IntPtr.Zero; } + lock (_nativeLock) + { + if (_directBuffer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_directBuffer); _directBuffer = IntPtr.Zero; } + } } void ReleaseNative() { ReleaseDirectBuffer(); - if (_consumer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_consumer); _consumer = IntPtr.Zero; } - if (_topDown != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_topDown); _topDown = IntPtr.Zero; } - if (_bottomUp != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_bottomUp); _bottomUp = IntPtr.Zero; } + lock (_nativeLock) + { + IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass }; + for (int i = 0; i < refs.Length; i++) + if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); + _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = IntPtr.Zero; + _timeMethod = _elapsedMethod = IntPtr.Zero; + } _pixels = null; } @@ -446,6 +594,9 @@ void OnDestroy() _stopWorker = true; _signal.Release(); if (_instance == this) _instance = null; + ReleaseRenderTextures(); + // A push still running owns the references until it finishes; the worker detaches on exit. + if (_policy == null || !_policy.HasInFlight) ReleaseNative(); } #else void Stop() { _enabled = false; } diff --git a/Runtime/WrapperPolicy/FramePushPolicy.cs b/Runtime/WrapperPolicy/FramePushPolicy.cs index b61b8e3..3d269e0 100644 --- a/Runtime/WrapperPolicy/FramePushPolicy.cs +++ b/Runtime/WrapperPolicy/FramePushPolicy.cs @@ -157,17 +157,31 @@ public static bool TryParseUnityVersion(string version, out int major, out int m } /// - /// Row order of the readback buffer. Textures whose UV origin is at the top (Vulkan, Metal, D3D) read back - /// top row first; GL-style textures read back bottom row first. An explicit override wins. Not verified on - /// devices: ScreenCapture's own orientation per graphics API is undocumented. + /// Row order of the readback buffer for a graphics API, or false when it was never measured. Measured + /// empirically with a known pattern (top red, bottom blue) through the whole capture path; see the tests. /// - public static VideoFrameRowOrder RowOrderFor( - bool uvStartsAtTop, VideoFrameRowOrder? overrideOrder) + public static bool TryMeasuredRowOrder(string graphicsApi, out VideoFrameRowOrder order) { - if (overrideOrder.HasValue) return overrideOrder.Value; - return uvStartsAtTop - ? VideoFrameRowOrder.TopDown - : VideoFrameRowOrder.BottomUp; + order = VideoFrameRowOrder.BottomUp; + switch (graphicsApi) + { + // Measured on an Android 16 arm64 emulator, Unity 2021.3.45: the first buffer row was the screen's bottom. + case "OpenGLES3": order = VideoFrameRowOrder.BottomUp; return true; + default: return false; + } + } + + /// + /// Row order to push with, and whether it is verified. An explicit override counts as verified (the host + /// declared it). An API without a measurement gets a guess and verified=false: the caller must not rely on + /// it for masking and blanks the whole frame instead when a secure rectangle is registered. + /// + public static VideoFrameRowOrder ResolveRowOrder(string graphicsApi, VideoFrameRowOrder? overrideOrder, out bool verified) + { + if (overrideOrder.HasValue) { verified = true; return overrideOrder.Value; } + if (TryMeasuredRowOrder(graphicsApi, out VideoFrameRowOrder measured)) { verified = true; return measured; } + verified = false; + return VideoFrameRowOrder.TopDown; } } } diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs index 3088e51..48bca31 100644 --- a/Runtime/WrapperPolicy/FrameRedaction.cs +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -10,53 +10,68 @@ namespace Bugsee.WrapperPolicy public static class FrameRedaction { /// - /// Fills each rectangle with opaque black. is the output of - /// SecureRectRegistry.Snapshot ([version, count, l, t, r, b, ...]) in screen pixels with a top-left origin; - /// it is scaled from x to the frame, and the - /// rows are mirrored when the buffer is bottom-up. Rounds outward (floor/ceil) so a mask never shrinks. - /// Returns the number of rectangles that touched the frame. No allocations. + /// Fills each rectangle with opaque black. holds rectangles as + /// [l, t, r, b, ...] in the native window pixels they were registered in (top-left origin); they are scaled + /// from x to the frame, and the rows are + /// mirrored when the buffer is bottom-up. Rounds outward (floor/ceil) so a mask never shrinks. Returns the + /// number of rectangles that touched the frame. No allocations. /// public static int BlankRects( byte[] rgba, int width, int height, VideoFrameRowOrder order, - int[] packedSnapshot, int screenWidth, int screenHeight) + int[] rects, int count, int sourceWidth, int sourceHeight) { - if (rgba == null || packedSnapshot == null || packedSnapshot.Length < 2) return 0; - if (width <= 0 || height <= 0 || screenWidth <= 0 || screenHeight <= 0) return 0; + if (rgba == null || rects == null || count <= 0 || rects.Length < count * 4) return 0; + if (width <= 0 || height <= 0 || sourceWidth <= 0 || sourceHeight <= 0) return 0; if (rgba.LongLength < (long)width * height * FramePushPolicy.PixelStride) return 0; - int count = packedSnapshot[1]; - if (count < 0 || packedSnapshot.Length < 2 + count * 4) return 0; - - double sx = (double)width / screenWidth; - double sy = (double)height / screenHeight; int applied = 0; for (int i = 0; i < count; i++) { - int o = 2 + i * 4; - int x0 = Clamp((int)System.Math.Floor(packedSnapshot[o] * sx), 0, width); - int y0 = Clamp((int)System.Math.Floor(packedSnapshot[o + 1] * sy), 0, height); - int x1 = Clamp((int)System.Math.Ceiling(packedSnapshot[o + 2] * sx), 0, width); - int y1 = Clamp((int)System.Math.Ceiling(packedSnapshot[o + 3] * sy), 0, height); + int o = i * 4; + int x0 = Clamp(FloorDiv((long)rects[o] * width, sourceWidth), 0, width); + int y0 = Clamp(FloorDiv((long)rects[o + 1] * height, sourceHeight), 0, height); + int x1 = Clamp(CeilDiv((long)rects[o + 2] * width, sourceWidth), 0, width); + int y1 = Clamp(CeilDiv((long)rects[o + 3] * height, sourceHeight), 0, height); if (x1 <= x0 || y1 <= y0) continue; for (int y = y0; y < y1; y++) { int row = order == VideoFrameRowOrder.BottomUp ? height - 1 - y : y; - int p = (row * width + x0) * FramePushPolicy.PixelStride; - for (int x = x0; x < x1; x++) - { - rgba[p] = 0; - rgba[p + 1] = 0; - rgba[p + 2] = 0; - rgba[p + 3] = 255; - p += FramePushPolicy.PixelStride; - } + FillBlack(rgba, (row * width + x0) * FramePushPolicy.PixelStride, x1 - x0); } applied++; } return applied; } - static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + /// Fills the whole frame with opaque black: the fail-closed choice when the row order is unverified and a secure rectangle is registered. + public static void BlankAll(byte[] rgba, int width, int height) + { + if (rgba == null || width <= 0 || height <= 0) return; + long pixels = (long)width * height; + if (rgba.LongLength < pixels * FramePushPolicy.PixelStride) return; + FillBlack(rgba, 0, (int)pixels); + } + + static void FillBlack(byte[] rgba, int start, int pixels) + { + int p = start; + for (int x = 0; x < pixels; x++) + { + rgba[p] = 0; + rgba[p + 1] = 0; + rgba[p + 2] = 0; + rgba[p + 3] = 255; + p += FramePushPolicy.PixelStride; + } + } + + // Exact integer scaling: a floating-point product such as 60 * 0.1 = 6.000000000000001 would round up a row. + static long FloorDiv(long a, long b) => a >= 0 ? a / b : -((-a + b - 1) / b); + + static long CeilDiv(long a, long b) => -FloorDiv(-a, b); + + static int Clamp(long v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : (int)v); + } } diff --git a/Runtime/WrapperPolicy/SecureRectRegistry.cs b/Runtime/WrapperPolicy/SecureRectRegistry.cs index 67edcf1..c01be11 100644 --- a/Runtime/WrapperPolicy/SecureRectRegistry.cs +++ b/Runtime/WrapperPolicy/SecureRectRegistry.cs @@ -9,6 +9,9 @@ public sealed class SecureRectRegistry public static SecureRectRegistry Instance => SharedInstance; + // Bugsee.AddSecureRectangle can run on any thread while the frame pusher reads every frame. + readonly object _sync = new object(); + readonly Dictionary<(string ownerId, int displayId), int[]> _rectsByOwnerDisplay = new Dictionary<(string ownerId, int displayId), int[]>(); @@ -23,69 +26,103 @@ sealed class DisplaySnapshotState public void Set(string ownerId, int displayId, int left, int top, int right, int bottom) { - int normalizedLeft = Math.Min(left, right); - int normalizedRight = Math.Max(left, right); - int normalizedTop = Math.Min(top, bottom); - int normalizedBottom = Math.Max(top, bottom); - _rectsByOwnerDisplay[(ownerId, displayId)] = new[] + lock (_sync) { - normalizedLeft, - normalizedTop, - normalizedRight, - normalizedBottom - }; + int normalizedLeft = Math.Min(left, right); + int normalizedRight = Math.Max(left, right); + int normalizedTop = Math.Min(top, bottom); + int normalizedBottom = Math.Max(top, bottom); + _rectsByOwnerDisplay[(ownerId, displayId)] = new[] + { + normalizedLeft, + normalizedTop, + normalizedRight, + normalizedBottom + }; + } } public void RemoveOwner(string ownerId) { - var keysToRemove = new List<(string ownerId, int displayId)>(); - foreach (var key in _rectsByOwnerDisplay.Keys) + lock (_sync) { - if (key.ownerId == ownerId) - keysToRemove.Add(key); + var keysToRemove = new List<(string ownerId, int displayId)>(); + foreach (var key in _rectsByOwnerDisplay.Keys) + { + if (key.ownerId == ownerId) + keysToRemove.Add(key); + } + + for (int i = 0; i < keysToRemove.Count; i++) + _rectsByOwnerDisplay.Remove(keysToRemove[i]); } - - for (int i = 0; i < keysToRemove.Count; i++) - _rectsByOwnerDisplay.Remove(keysToRemove[i]); } public void RemoveOwnersWithPrefix(string ownerIdPrefix) { - if (string.IsNullOrEmpty(ownerIdPrefix)) - return; - - var keysToRemove = new List<(string ownerId, int displayId)>(); - foreach (var key in _rectsByOwnerDisplay.Keys) + lock (_sync) { - if (key.ownerId != null && key.ownerId.StartsWith(ownerIdPrefix)) - keysToRemove.Add(key); + if (string.IsNullOrEmpty(ownerIdPrefix)) + return; + + var keysToRemove = new List<(string ownerId, int displayId)>(); + foreach (var key in _rectsByOwnerDisplay.Keys) + { + if (key.ownerId != null && key.ownerId.StartsWith(ownerIdPrefix)) + keysToRemove.Add(key); + } + + for (int i = 0; i < keysToRemove.Count; i++) + _rectsByOwnerDisplay.Remove(keysToRemove[i]); } - - for (int i = 0; i < keysToRemove.Count; i++) - _rectsByOwnerDisplay.Remove(keysToRemove[i]); } public int[] Snapshot(int displayId, float pixelsPerNativeUnit) { - ValidatePixelsPerNativeUnit(pixelsPerNativeUnit); + lock (_sync) + { + ValidatePixelsPerNativeUnit(pixelsPerNativeUnit); - int[] converted = BuildConvertedRects(displayId, pixelsPerNativeUnit); - DisplaySnapshotState state = GetOrCreateDisplayState(displayId); + int[] converted = BuildConvertedRects(displayId, pixelsPerNativeUnit); + DisplaySnapshotState state = GetOrCreateDisplayState(displayId); - if (state.LastPublishedRects == null) - { - state.Version = 1; - state.LastPublishedRects = Array.Empty(); + if (state.LastPublishedRects == null) + { + state.Version = 1; + state.LastPublishedRects = Array.Empty(); + } + + if (!RectBuffersEqual(state.LastPublishedRects, converted)) + { + int next = state.Version + 1; + state.Version = next == 1 ? 2 : next; + state.LastPublishedRects = (int[])converted.Clone(); + } + + return PackSnapshot(state.Version, converted); } + } - if (!RectBuffersEqual(state.LastPublishedRects, converted)) + /// + /// Copies the rectangles of a display as [l, t, r, b, ...] (registered pixels, no scaling) into + /// , growing it when too small, and returns the rectangle count. Thread-safe; allocates + /// only when the buffer has to grow, so it can run every frame. + /// + public int CopyRects(int displayId, ref int[] dest) + { + lock (_sync) { - int next = state.Version + 1; - state.Version = next == 1 ? 2 : next; - state.LastPublishedRects = (int[])converted.Clone(); + int count = 0; + foreach (var entry in _rectsByOwnerDisplay) + { + if (entry.Key.displayId != displayId) continue; + if (dest == null || dest.Length < (count + 1) * 4) + Array.Resize(ref dest, Math.Max(16, (count + 1) * 8)); + Array.Copy(entry.Value, 0, dest, count * 4, 4); + count++; + } + return count; } - - return PackSnapshot(state.Version, converted); } static int[] PackSnapshot(int version, int[] convertedRects) diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 838a14e..6781999 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -126,7 +126,14 @@ public void LaunchSdk() options.Endpoint = endpointOverride.Trim(); Bugsee.Launch(appToken, options); if (experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1") - global::Bugsee.Capture.BugseeFrameCapturer.Enable(); + { + // "bugsee_frame_pattern" = 1 also paints a red-over-blue test pattern and logs the readback rows. + var pattern = ReadE2EValue("bugsee_frame_pattern", "BUGSEE_FRAME_PATTERN") == "1"; + if (pattern) + gameObject.AddComponent(); + global::Bugsee.Capture.BugseeFrameCapturer.Enable( + new global::Bugsee.Capture.BugseeFrameCapturerOptions { LogOrientationProbe = pattern }); + } #elif UNITY_IOS && !UNITY_EDITOR var iosOptions = new IOSLaunchOptions { diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs new file mode 100644 index 0000000..bfe101c --- /dev/null +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs @@ -0,0 +1,31 @@ +using UnityEngine; + +namespace Bugsee.Sample +{ + /// Paints the top half of the screen red and the bottom half blue, to check which readback row of the frame pusher is the screen's top. + public sealed class OrientationPatternOverlay : MonoBehaviour + { + Texture2D _red, _blue; + + void Awake() + { + _red = Solid(Color.red); + _blue = Solid(Color.blue); + } + + static Texture2D Solid(Color color) + { + var texture = new Texture2D(1, 1); + texture.SetPixel(0, 0, color); + texture.Apply(); + return texture; + } + + void OnGUI() + { + GUI.depth = -1000; + GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height / 2f), _red); + GUI.DrawTexture(new Rect(0, Screen.height / 2f, Screen.width, Screen.height / 2f), _blue); + } + } +} diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs.meta b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs.meta new file mode 100644 index 0000000..a7d71ff --- /dev/null +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 760a22f43b77499d9b31e7c86329277d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index 5230222..14666e6 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -134,11 +134,28 @@ public void Readback_gate(bool supported, bool vulkan, string version, bool expe } [Test] - public void Row_order_follows_the_uv_origin_unless_overridden() + public void Measured_gles3_is_bottom_up_and_verified() { - Assert.That(FramePushPolicy.RowOrderFor(true, null), Is.EqualTo(VideoFrameRowOrder.TopDown)); - Assert.That(FramePushPolicy.RowOrderFor(false, null), Is.EqualTo(VideoFrameRowOrder.BottomUp)); - Assert.That(FramePushPolicy.RowOrderFor(true, VideoFrameRowOrder.BottomUp), Is.EqualTo(VideoFrameRowOrder.BottomUp)); + Assert.That(FramePushPolicy.ResolveRowOrder("OpenGLES3", null, out bool verified), Is.EqualTo(VideoFrameRowOrder.BottomUp)); + Assert.That(verified, Is.True); + } + + [TestCase("Vulkan")] + [TestCase("OpenGLES2")] + [TestCase("")] + [TestCase(null)] + public void Unmeasured_apis_are_unverified(string api) + { + FramePushPolicy.ResolveRowOrder(api, null, out bool verified); + Assert.That(verified, Is.False); + } + + [Test] + public void An_explicit_override_wins_and_counts_as_verified() + { + Assert.That(FramePushPolicy.ResolveRowOrder("Vulkan", VideoFrameRowOrder.TopDown, out bool verified), Is.EqualTo(VideoFrameRowOrder.TopDown)); + Assert.That(verified, Is.True); + Assert.That(FramePushPolicy.ResolveRowOrder("OpenGLES3", VideoFrameRowOrder.TopDown, out _), Is.EqualTo(VideoFrameRowOrder.TopDown)); } } @@ -146,16 +163,16 @@ public class FrameRedactionTests { const int W = 4, H = 4; - static byte[] White() { var b = new byte[W * H * 4]; for (int i = 0; i < b.Length; i++) b[i] = 255; return b; } - static bool Blank(byte[] b, int x, int row) { int o = (row * W + x) * 4; return b[o] == 0 && b[o + 1] == 0 && b[o + 2] == 0 && b[o + 3] == 255; } - static bool Untouched(byte[] b, int x, int row) { int o = (row * W + x) * 4; return b[o] == 255 && b[o + 1] == 255 && b[o + 2] == 255; } - static int[] One(int l, int t, int r, int bt) => new[] { 1, 1, l, t, r, bt }; + static byte[] White(int w = W, int h = H) { var b = new byte[w * h * 4]; for (int i = 0; i < b.Length; i++) b[i] = 255; return b; } + static bool Blank(byte[] b, int x, int row, int w = W) { int o = (row * w + x) * 4; return b[o] == 0 && b[o + 1] == 0 && b[o + 2] == 0 && b[o + 3] == 255; } + static bool Untouched(byte[] b, int x, int row, int w = W) { int o = (row * w + x) * 4; return b[o] == 255 && b[o + 1] == 255 && b[o + 2] == 255; } + static int[] One(int l, int t, int r, int bt) => new[] { l, t, r, bt }; [Test] public void Top_left_rect_blanks_the_top_rows_of_a_top_down_frame() { var b = White(); - Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 2, 1), 4, 4), Is.EqualTo(1)); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 2, 1), 1, 4, 4), Is.EqualTo(1)); Assert.That(Blank(b, 0, 0) && Blank(b, 1, 0), Is.True); Assert.That(Untouched(b, 2, 0) && Untouched(b, 0, 1), Is.True); } @@ -164,17 +181,34 @@ public void Top_left_rect_blanks_the_top_rows_of_a_top_down_frame() public void Same_rect_lands_on_the_last_rows_of_a_bottom_up_frame() { var b = White(); - FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.BottomUp, One(0, 0, 2, 1), 4, 4); + FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.BottomUp, One(0, 0, 2, 1), 1, 4, 4); Assert.That(Blank(b, 0, H - 1) && Blank(b, 1, H - 1), Is.True); Assert.That(Untouched(b, 0, 0), Is.True); } [Test] - public void Rect_is_scaled_from_screen_pixels_and_rounded_outward() + public void Bottom_up_with_scaling_masks_the_rows_that_show_the_screen_region() + { + // 100x200 native window, rect in its top-left corner (10,20)-(50,60); frame is 10x20 (scale 0.1), + // bottom-up, so screen rows 2..5 are buffer rows 19-5..19-2 = 14..17 and columns 1..4. + const int fw = 10, fh = 20; + var b = White(fw, fh); + Assert.That(FrameRedaction.BlankRects(b, fw, fh, VideoFrameRowOrder.BottomUp, One(10, 20, 50, 60), 1, 100, 200), Is.EqualTo(1)); + for (int row = 0; row < fh; row++) + for (int x = 0; x < fw; x++) + { + bool inside = row >= 14 && row <= 17 && x >= 1 && x <= 4; + Assert.That(Blank(b, x, row, fw), Is.EqualTo(inside), "x=" + x + " row=" + row); + Assert.That(Untouched(b, x, row, fw), Is.EqualTo(!inside), "x=" + x + " row=" + row); + } + } + + [Test] + public void Rect_is_scaled_from_window_pixels_and_rounded_outward() { var b = White(); - // 8x8 screen, rect (1,1)-(3,3) -> frame 4x4 scale 0.5 -> floor(0.5)=0 .. ceil(1.5)=2 - FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(1, 1, 3, 3), 8, 8); + // 8x8 window, rect (1,1)-(3,3) -> frame 4x4 scale 0.5 -> floor(0.5)=0 .. ceil(1.5)=2 + FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(1, 1, 3, 3), 1, 8, 8); Assert.That(Blank(b, 0, 0) && Blank(b, 1, 1), Is.True); Assert.That(Untouched(b, 2, 2), Is.True); } @@ -183,80 +217,31 @@ public void Rect_is_scaled_from_screen_pixels_and_rounded_outward() public void Out_of_range_and_degenerate_rects_are_clamped_or_ignored() { var b = White(); - var packed = new[] { 1, 3, -10, -10, 100, 100, 2, 2, 2, 3, 3, 3, 1, 1 }; - Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, packed, 4, 4), Is.EqualTo(1)); + var rects = new[] { -10, -10, 100, 100, 2, 2, 2, 3, 3, 3, 1, 1 }; + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, rects, 3, 4, 4), Is.EqualTo(1)); for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(b, x, y), Is.True); } [Test] - public void Bad_input_changes_nothing() + public void Blank_all_blanks_every_pixel_and_ignores_a_short_buffer() { var b = White(); - Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, null, 4, 4), Is.EqualTo(0)); - Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, new[] { 1, 5, 0, 0, 1, 1 }, 4, 4), Is.EqualTo(0)); - Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 0, 4), Is.EqualTo(0)); - Assert.That(FrameRedaction.BlankRects(new byte[3], W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 4, 4), Is.EqualTo(0)); - Assert.That(Untouched(b, 0, 0), Is.True); - } - } - - public class FrameCapturerSourceTests - { - static string Source() - { - var dir = TestContext.CurrentContext.TestDirectory; - while (!File.Exists(Path.Combine(dir, "package.json"))) dir = Directory.GetParent(dir).FullName; - return File.ReadAllText(Path.Combine(dir, "Runtime", "Capture", "BugseeFrameCapturer.cs")); - } - - static string Code() - { - var lines = Source().Split('\n'); - var kept = new System.Text.StringBuilder(); - foreach (var l in lines) - { - var t = l.TrimStart(); - if (t.StartsWith("//") || t.StartsWith("///") || t.StartsWith("*")) continue; - kept.AppendLine(l); - } - return kept.ToString(); - } - - [Test] - public void Readback_is_asynchronous_only() - { - var code = Code(); - Assert.That(code, Does.Contain("AsyncGPUReadback.Request(")); - Assert.That(code, Does.Not.Contain("WaitForCompletion")); - Assert.That(code, Does.Not.Contain("ReadPixels")); - Assert.That(code, Does.Not.Contain("CaptureScreenshotAsTexture")); - } - - [Test] - public void Capture_is_off_unless_enabled_and_android_only() - { - var code = Code(); - Assert.That(code, Does.Not.Contain("RuntimeInitializeOnLoadMethod")); - Assert.That(code, Does.Not.Contain("void Awake")); - Assert.That(code, Does.Contain("#if UNITY_ANDROID && !UNITY_EDITOR")); - Assert.That(code, Does.Contain("public static bool Enable(")); - } - - [Test] - public void Every_request_goes_through_the_policy_and_the_push_uses_the_policy_pixel_stride() - { - var code = Code(); - Assert.That(code, Does.Contain("_policy.TryBegin(")); - Assert.That(code, Does.Contain("_policy.Complete(")); - Assert.That(code, Does.Contain("FrameRedaction.BlankRects(")); - Assert.That(code, Does.Contain("_args[5].i = FramePushPolicy.PixelStride;")); + FrameRedaction.BlankAll(b, W, H); + for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(b, x, y), Is.True); + var shortBuffer = new byte[3]; + FrameRedaction.BlankAll(shortBuffer, W, H); + Assert.That(shortBuffer, Is.EqualTo(new byte[3])); } [Test] - public void Push_signature_matches_VideoFrameConsumer_addFrame_7_4_1() + public void Bad_input_changes_nothing() { - Assert.That(Code(), Does.Contain("\"(JILjava/nio/ByteBuffer;IIIL\" + RowOrderClass + \";)V\"")); - Assert.That(Code(), Does.Contain("com/bugsee/library/contracts/capture/consumers/VideoFrameConsumer")); + var b = White(); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, null, 1, 4, 4), Is.EqualTo(0)); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, new[] { 0, 0, 1, 1 }, 5, 4, 4), Is.EqualTo(0)); + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 1, 0, 4), Is.EqualTo(0)); + Assert.That(FrameRedaction.BlankRects(new byte[3], W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 1, 4, 4), Is.EqualTo(0)); + Assert.That(Untouched(b, 0, 0), Is.True); } } } diff --git a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs index 76f1c0c..1b122f9 100644 --- a/Tests~/WrapperPolicy/SecureRectRegistryTests.cs +++ b/Tests~/WrapperPolicy/SecureRectRegistryTests.cs @@ -5,6 +5,35 @@ namespace Bugsee.WrapperPolicy.Tests { public class SecureRectRegistryTests { + [Test] + public void CopyRects_returns_the_rects_of_one_display_without_scaling_and_reuses_the_buffer() + { + var registry = new SecureRectRegistry(); + registry.Set("a", 0, 5, 6, 1, 2); // normalised to 1,2,5,6 + registry.Set("b", 1, 9, 9, 10, 10); + int[] buffer = null; + Assert.That(registry.CopyRects(0, ref buffer), Is.EqualTo(1)); + Assert.That(buffer[0], Is.EqualTo(1)); + Assert.That(buffer[3], Is.EqualTo(6)); + int[] same = buffer; + Assert.That(registry.CopyRects(0, ref buffer), Is.EqualTo(1)); + Assert.That(buffer, Is.SameAs(same)); + Assert.That(registry.CopyRects(2, ref buffer), Is.EqualTo(0)); + } + + [Test] + public void CopyRects_is_safe_while_another_thread_registers_rects() + { + var registry = new SecureRectRegistry(); + var writer = System.Threading.Tasks.Task.Run(() => + { + for (int i = 0; i < 5000; i++) { registry.Set("o" + (i % 50), 0, 0, 0, i, i); if (i % 7 == 0) registry.RemoveOwner("o" + (i % 50)); } + }); + int[] buffer = null; + Assert.DoesNotThrow(() => { while (!writer.IsCompleted) registry.CopyRects(0, ref buffer); }); + writer.Wait(); + } + [Test] public void Ios_scale_3_rounds_outward_after_convert() { From 49be20d72d4f4655aaedf403abb3cecbea8f3750 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 14:05:12 +0500 Subject: [PATCH 122/157] Mask the union of the rects at capture start and at readback. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Capture/BugseeFrameCapturer.cs | 9 ++++- Runtime/WrapperPolicy/FrameRedaction.cs | 12 ++++++ Tests~/WrapperPolicy/FramePushPolicyTests.cs | 42 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index fda6b19..5102b2c 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -149,6 +149,8 @@ BugseeFrameCapturerStats ReadStats() // Size of the screen at request time, in native window pixels (what secure rectangles are registered in). int _lastScreenW, _lastScreenH, _nativeW, _nativeH, _requestNativeW, _requestNativeH; int[] _rects; + int[] _requestRects; // rectangles registered when the capture started + int _requestRectCount; int _probeFrames; // Pixels: the readback callback (main thread) fills _pixels, the push thread copies them into the Java @@ -360,6 +362,7 @@ void Tick() _requestTimestampMs = NowMs(); _requestNativeW = _nativeW; _requestNativeH = _nativeH; + _requestRectCount = SecureRectRegistry.Instance.CopyRects(0, ref _requestRects); ScreenCapture.CaptureScreenshotIntoRenderTexture(_fullRt); Graphics.Blit(_fullRt, _smallRt); AsyncGPUReadback.Request(_smallRt, 0, TextureFormat.RGBA32, _onReadback); @@ -469,11 +472,13 @@ void OnReadback(AsyncGPUReadbackRequest request) // The SDK does not mask pushed frames: blank the registered secure rectangles first. If the row order is // unverified the mask could land on mirrored rows and leak the real area, so blank the whole frame instead. + // Mask what was secure when the capture started as well as what is secure now. int count = SecureRectRegistry.Instance.CopyRects(0, ref _rects); - if (count > 0) + if (count > 0 || _requestRectCount > 0) { if (_rowOrderVerified) - FrameRedaction.BlankRects(_pixels, _captureW, _captureH, _rowOrder, _rects, count, _requestNativeW, _requestNativeH); + FrameRedaction.BlankRequestAndLive(_pixels, _captureW, _captureH, _rowOrder, + _requestRects, _requestRectCount, _rects, count, _requestNativeW, _requestNativeH); else FrameRedaction.BlankAll(_pixels, _captureW, _captureH); } diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs index 48bca31..432af03 100644 --- a/Runtime/WrapperPolicy/FrameRedaction.cs +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -44,6 +44,18 @@ public static int BlankRects( return applied; } + /// + /// Blanks the union of the rectangles registered when the capture started and those registered when the pixels + /// arrived, so a rectangle removed or moved in between never unmasks something that was secure at capture time. + /// + public static int BlankRequestAndLive( + byte[] rgba, int width, int height, VideoFrameRowOrder order, + int[] requestRects, int requestCount, int[] liveRects, int liveCount, int sourceWidth, int sourceHeight) + { + return BlankRects(rgba, width, height, order, requestRects, requestCount, sourceWidth, sourceHeight) + + BlankRects(rgba, width, height, order, liveRects, liveCount, sourceWidth, sourceHeight); + } + /// Fills the whole frame with opaque black: the fail-closed choice when the row order is unverified and a secure rectangle is registered. public static void BlankAll(byte[] rgba, int width, int height) { diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index 14666e6..f668420 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -222,6 +222,48 @@ public void Out_of_range_and_degenerate_rects_are_clamped_or_ignored() for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(b, x, y), Is.True); } + [Test] + public void Rect_removed_after_the_request_started_is_still_masked() + { + var registry = new SecureRectRegistry(); + registry.Set("a", 0, 0, 0, 2, 1); + int[] atRequest = null, live = null; + int requestCount = registry.CopyRects(0, ref atRequest); + registry.RemoveOwner("a"); + int liveCount = registry.CopyRects(0, ref live); + var b = White(); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, live, liveCount, 4, 4); + Assert.That(Blank(b, 0, 0) && Blank(b, 1, 0), Is.True); + } + + [Test] + public void Rect_added_after_the_request_started_is_masked_too() + { + var registry = new SecureRectRegistry(); + int[] atRequest = null, live = null; + int requestCount = registry.CopyRects(0, ref atRequest); + registry.Set("late", 0, 0, 2, 4, 3); + int liveCount = registry.CopyRects(0, ref live); + var b = White(); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, live, liveCount, 4, 4); + Assert.That(Blank(b, 3, 2), Is.True); + Assert.That(Untouched(b, 0, 0), Is.True); + } + + [Test] + public void Rect_moved_after_the_request_started_masks_both_places() + { + var registry = new SecureRectRegistry(); + registry.Set("m", 0, 0, 0, 1, 1); + int[] atRequest = null, live = null; + int requestCount = registry.CopyRects(0, ref atRequest); + registry.Set("m", 0, 3, 3, 4, 4); + int liveCount = registry.CopyRects(0, ref live); + var b = White(); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, live, liveCount, 4, 4); + Assert.That(Blank(b, 0, 0) && Blank(b, 3, 3), Is.True); + } + [Test] public void Blank_all_blanks_every_pixel_and_ignores_a_short_buffer() { From f21f173398505199c5c8340391ab12496c95e0b9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 14:24:36 +0500 Subject: [PATCH 123/157] Map live secure rects with the native window size at readback time. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Capture/BugseeFrameCapturer.cs | 7 +++-- Runtime/WrapperPolicy/FrameRedaction.cs | 10 +++--- .../BugseeHarness/BugseeSampleBootstrap.cs | 25 +++++++++++++++ Tests~/WrapperPolicy/FramePushPolicyTests.cs | 31 +++++++++++++++++-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index 5102b2c..1bd6e3f 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -472,13 +472,16 @@ void OnReadback(AsyncGPUReadbackRequest request) // The SDK does not mask pushed frames: blank the registered secure rectangles first. If the row order is // unverified the mask could land on mirrored rows and leak the real area, so blank the whole frame instead. - // Mask what was secure when the capture started as well as what is secure now. + // Mask what was secure when the capture started as well as what is secure now. Live rectangles are in the + // window size of now (it may have changed since the request), request-time ones in the size of the request. + UpdateNativeSize(); int count = SecureRectRegistry.Instance.CopyRects(0, ref _rects); if (count > 0 || _requestRectCount > 0) { if (_rowOrderVerified) FrameRedaction.BlankRequestAndLive(_pixels, _captureW, _captureH, _rowOrder, - _requestRects, _requestRectCount, _rects, count, _requestNativeW, _requestNativeH); + _requestRects, _requestRectCount, _requestNativeW, _requestNativeH, + _rects, count, _nativeW, _nativeH); else FrameRedaction.BlankAll(_pixels, _captureW, _captureH); } diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs index 432af03..4829343 100644 --- a/Runtime/WrapperPolicy/FrameRedaction.cs +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -46,14 +46,16 @@ public static int BlankRects( /// /// Blanks the union of the rectangles registered when the capture started and those registered when the pixels - /// arrived, so a rectangle removed or moved in between never unmasks something that was secure at capture time. + /// arrived, so a rectangle removed or moved in between never unmasks something that was secure at capture time. Each set is + /// scaled with the native window size of its own moment: after a rotation or resize the live rectangles are in the new pixel space. /// public static int BlankRequestAndLive( byte[] rgba, int width, int height, VideoFrameRowOrder order, - int[] requestRects, int requestCount, int[] liveRects, int liveCount, int sourceWidth, int sourceHeight) + int[] requestRects, int requestCount, int requestSourceWidth, int requestSourceHeight, + int[] liveRects, int liveCount, int liveSourceWidth, int liveSourceHeight) { - return BlankRects(rgba, width, height, order, requestRects, requestCount, sourceWidth, sourceHeight) - + BlankRects(rgba, width, height, order, liveRects, liveCount, sourceWidth, sourceHeight); + return BlankRects(rgba, width, height, order, requestRects, requestCount, requestSourceWidth, requestSourceHeight) + + BlankRects(rgba, width, height, order, liveRects, liveCount, liveSourceWidth, liveSourceHeight); } /// Fills the whole frame with opaque black: the fail-closed choice when the row order is unverified and a secure rectangle is registered. diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 6781999..d3c4b96 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -133,6 +133,11 @@ public void LaunchSdk() gameObject.AddComponent(); global::Bugsee.Capture.BugseeFrameCapturer.Enable( new global::Bugsee.Capture.BugseeFrameCapturerOptions { LogOrientationProbe = pattern }); + // "bugsee_secure_rect" = "l,t,r,b" as fractions of the screen (top-left origin); it is removed after + // "bugsee_secure_rect_remove_s" seconds when that is set. For checking the mask in the recorded video. + var rect = ReadE2EValue("bugsee_secure_rect", "BUGSEE_SECURE_RECT"); + if (!string.IsNullOrEmpty(rect)) + StartCoroutine(SecureRectDemo(rect, ReadE2EValue("bugsee_secure_rect_remove_s", "BUGSEE_SECURE_RECT_REMOVE_S"))); } #elif UNITY_IOS && !UNITY_EDITOR var iosOptions = new IOSLaunchOptions @@ -366,6 +371,26 @@ void FireProcessFailure(string scenario) } } + IEnumerator SecureRectDemo(string fractions, string removeAfterSeconds) + { + yield return new WaitForSeconds(2f); + var p = fractions.Split(','); + if (p.Length != 4) yield break; + var inv = System.Globalization.CultureInfo.InvariantCulture; + var rect = new RectInt( + (int)(float.Parse(p[0], inv) * Screen.width), (int)(float.Parse(p[1], inv) * Screen.height), + (int)((float.Parse(p[2], inv) - float.Parse(p[0], inv)) * Screen.width), + (int)((float.Parse(p[3], inv) - float.Parse(p[1], inv)) * Screen.height)); + Bugsee.AddSecureRectangle(rect); + Debug.Log("[BugseeSample] secure rect added " + rect); + if (float.TryParse(removeAfterSeconds, System.Globalization.NumberStyles.Float, inv, out var seconds)) + { + yield return new WaitForSeconds(seconds); + Bugsee.RemoveSecureRectangle(rect); + Debug.Log("[BugseeSample] secure rect removed"); + } + } + // An intent extra (Android) or environment variable (iOS), trimmed and lower-cased, or null when there is // none. "bugsee_e2e" / BUGSEE_E2E_SCENARIO names the scenario; "bugsee_e2e_run" / BUGSEE_E2E_RUN is a // unique id per launch from the run script. diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index f668420..a54a718 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -232,7 +232,7 @@ public void Rect_removed_after_the_request_started_is_still_masked() registry.RemoveOwner("a"); int liveCount = registry.CopyRects(0, ref live); var b = White(); - FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, live, liveCount, 4, 4); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, 4, 4, live, liveCount, 4, 4); Assert.That(Blank(b, 0, 0) && Blank(b, 1, 0), Is.True); } @@ -245,7 +245,7 @@ public void Rect_added_after_the_request_started_is_masked_too() registry.Set("late", 0, 0, 2, 4, 3); int liveCount = registry.CopyRects(0, ref live); var b = White(); - FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, live, liveCount, 4, 4); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, 4, 4, live, liveCount, 4, 4); Assert.That(Blank(b, 3, 2), Is.True); Assert.That(Untouched(b, 0, 0), Is.True); } @@ -260,10 +260,35 @@ public void Rect_moved_after_the_request_started_masks_both_places() registry.Set("m", 0, 3, 3, 4, 4); int liveCount = registry.CopyRects(0, ref live); var b = White(); - FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, live, liveCount, 4, 4); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, atRequest, requestCount, 4, 4, live, liveCount, 4, 4); Assert.That(Blank(b, 0, 0) && Blank(b, 3, 3), Is.True); } + [Test] + public void Rect_added_after_a_rotation_is_mapped_with_the_new_window_size() + { + // At request time the window was 8x4 (landscape), after rotating it is 4x8; a rect registered in the new + // space at (0,4)-(4,8) is the bottom half of the 4x4 frame, not the area the old 8x4 space would give. + var b = White(); + int[] request = null, live = One(0, 4, 4, 8); + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, request, 0, 8, 4, live, 1, 4, 8); + for (int x = 0; x < W; x++) + { + Assert.That(Untouched(b, x, 0) && Untouched(b, x, 1), Is.True, "x=" + x); + Assert.That(Blank(b, x, 2) && Blank(b, x, 3), Is.True, "x=" + x); + } + } + + [Test] + public void Request_rects_keep_the_request_time_window_size_after_a_rotation() + { + var b = White(); + int[] request = One(0, 0, 4, 2); // left half of the top half of the old 8x4 window + FrameRedaction.BlankRequestAndLive(b, W, H, VideoFrameRowOrder.TopDown, request, 1, 8, 4, null, 0, 4, 8); + Assert.That(Blank(b, 0, 0) && Blank(b, 1, 1), Is.True); + Assert.That(Untouched(b, 2, 0) && Untouched(b, 0, 2), Is.True); + } + [Test] public void Blank_all_blanks_every_pixel_and_ignores_a_short_buffer() { From 4e064448d0ab0563d761422d8c32030a47216042 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 14:42:30 +0500 Subject: [PATCH 124/157] Frame pusher: hand frames to Java through a cache file, measure Vulkan, add timing stats. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidJNI.ToByteArray cost about 0.9 s per frame on a Galaxy Tab S5e; a FileChannel read into the reused direct buffer costs about 2 ms. Vulkan row order measured top-down on the tablet. Sample gets secure-rect and pattern switches for checking the recorded video. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 6 +- Runtime/Capture/BugseeFrameCapturer.cs | 99 ++++++++++++++----- Runtime/WrapperPolicy/FramePushPolicy.cs | 2 + .../BugseeHarness/BugseeSampleBootstrap.cs | 10 +- Tests~/WrapperPolicy/FramePushPolicyTests.cs | 10 +- 6 files changed, 94 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7c858..a9cd1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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`, reused buffers, one request in flight, frames dropped meanwhile, throttled to `TargetFps`, default 5). It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (the SDK does not mask them); blackout is honoured by the SDK. Readback failures turn it off (no synchronous fallback), and Vulkan needs Unity 2021.3.18 or newer. **Not benchmarked on devices.** The readback row order was measured for OpenGLES3 only; on any other graphics API it is unverified and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Pixelation and secure views are not applied to pushed frames. Needs the `com.unity.modules.screencapture` module (compiled out without it). Nothing changes unless you call `Enable()`: the default capture stays the SDK's own (`VideoMode.V2`), and a launch with `DirectBuffers` but no `Enable()` gets no frames from Unity (nothing pushes them). Refs #22 (benchmarking V2 against this on real devices is outstanding). +- 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`, reused buffers and a private cache file as the hand-over to Java, one request in flight, frames dropped meanwhile, throttled to `TargetFps`, default 5). It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (the SDK does not mask them); blackout is honoured by the SDK. Readback failures turn it off (no synchronous fallback), and Vulkan needs Unity 2021.3.18 or newer. **Not benchmarked on devices.** The readback row order was measured for OpenGLES3 (emulator) and Vulkan (Galaxy Tab S5e); on any other graphics API it is unverified and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Pixelation and secure views are not applied to pushed frames. Needs the `com.unity.modules.screencapture` module (compiled out without it). Nothing changes unless you call `Enable()`: the default capture stays the SDK's own (`VideoMode.V2`), and a launch with `DirectBuffers` but no `Enable()` gets no frames from Unity (nothing pushes them). Refs #22 (benchmarking V2 against this on real 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index 686fb9b..3894dfd 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -249,9 +249,9 @@ What to know before using it: - Needs the Screen Capture built-in module (`com.unity.modules.screencapture`, on by default in new projects); without it the capturer is compiled out and `Enable()` returns false. Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. - It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. - Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (the activity window size, top-left origin) and scaled to the frame. Secure native views are not known to it. Blackout is honoured by the SDK. -- Row order of the readback was measured only for OpenGLES3 (Android 16 arm64 emulator, Unity 2021.3.45: the first buffer row is the screen bottom, so BottomUp). For any other graphics API (Vulkan, OpenGLES2) it is unverified, and the pusher fails closed: while a secure rectangle is registered, every pushed frame is fully black, and a warning is logged. Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. Without a secure rectangle an unverified order can only show the video upside down. +- Row order of the readback was measured for OpenGLES3 (Android 16 arm64 emulator: first buffer row is the screen bottom, BottomUp) and Vulkan (Galaxy Tab S5e, Android 11, Adreno 615: first row is the screen top, TopDown; the decoded report video showed red on top for a red-over-blue test pattern). For any other graphics API (OpenGLES2) it is unverified, and the pusher fails closed: while a secure rectangle is registered, every pushed frame is fully black, and a warning is logged. Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (crash in older builds). After 5 failed readbacks in a row it turns itself off. -- Unity 2021.3's `AndroidJNI` cannot expose native memory as a `ByteBuffer` (later Unity versions can; not used here), so each pushed frame costs one Java `byte[]` (about 0.7 MB at 640 px) plus a copy into a reused direct buffer. Lower `MaxDimension` or `TargetFps` to reduce it. Secure rectangles are copied into a reused buffer under a lock, so that part does not allocate per frame. +- Unity 2021.3's `AndroidJNI` cannot expose native memory as a `ByteBuffer` (later Unity versions can; not used here). `AndroidJNI.ToByteArray` allocates a Java array per frame and measured about 0.9 s per 1 MB on the tablet (1 frame/s), so each frame is written to a private cache file (`Application.temporaryCachePath/bugsee_frame.raw`, already redacted, deleted on disable) and read by `FileChannel.read` into a reused direct buffer: about 2 ms per 1 MB frame. The frame therefore touches app-private storage briefly. Lower `MaxDimension` or `TargetFps` to reduce the cost. Secure rectangles are copied into reused buffers under a lock, so that part does not allocate per frame. - Frames are stamped with the SDK's own clock (`TimeUtils.monotonicWallTimeMillis`, falling back to boot time plus `SystemClock.elapsedRealtime`). - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. -- Exercised only on an Android 16 arm64 emulator (IL2CPP development build, OpenGLES3): frames were pushed and the SDK wrote video exports. Video content was not inspected, and there are no device or performance numbers. +- Measured on a Galaxy Tab S5e (Android 11, Vulkan, IL2CPP development build, sample scene, 400x640 capture at `TargetFps` 5, about 2.5 minutes each): about 4.5 frames/s pushed, 2 in-flight drops, 0 failures; average readback latency 88 ms, file copy 2 ms, `addFrame` 7.5 ms. Compared with the pusher off, the Unity main thread stayed at about 45% of a core and the render thread at about 17-19%; the push thread added about 3-6%, and total PSS was unchanged (about 325 MB either way). This is one device and one light scene, not a benchmark of heavier games, and the surface-level frame rate with the pusher on versus off was not measured (`gfxinfo` does not see a Unity surface). diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index 1bd6e3f..8271b8c 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -6,6 +6,7 @@ using UnityEngine; #if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE +using System.IO; using System.Threading; using Unity.Collections; using UnityEngine.Rendering; @@ -153,15 +154,22 @@ BugseeFrameCapturerStats ReadStats() int _requestRectCount; int _probeFrames; + // Timing for the stats line (average ms): request to readback callback, copy into the Java buffer, addFrame. + long _requestTicks, _latencySum, _copySum, _addSum, _timedFrames; + // Pixels: the readback callback (main thread) fills _pixels, the push thread copies them into the Java // direct buffer and calls addFrame. Unity 2021.3's AndroidJNI cannot wrap or address native memory as a - // ByteBuffer (later Unity versions can; not used here), so the copy goes through a Java byte[] (one Java-heap - // array per pushed frame, no managed allocation). Only one request is in flight, so neither side overwrites - // the other. + // ByteBuffer (later Unity versions can; not used here). AndroidJNI.ToByteArray allocates a Java array per + // frame and measured about 0.9 s per 1 MB on a Galaxy Tab S5e, so the frame goes through a private cache file + // instead: written from C#, read by FileChannel.read into the reused direct buffer. Only one request is in + // flight, so neither side overwrites the other. + FileStream _frameFile; + string _framePath; + IntPtr _channel, _channelRead; // global ref to the FileChannel of the file above, and its read(ByteBuffer, long) byte[] _pixels; readonly object _nativeLock = new object(); IntPtr _directBuffer; // global ref to a reused direct java.nio.ByteBuffer - IntPtr _bufferClear, _bufferRewind, _bufferPut; // method ids on java.nio.Buffer / ByteBuffer + IntPtr _bufferClear, _bufferRewind; // method ids on java.nio.Buffer IntPtr _consumer; // global ref to the VideoFrameConsumer IntPtr _addFrameMethod; IntPtr _topDown, _bottomUp; // global refs to the RowOrder constants @@ -169,7 +177,7 @@ BugseeFrameCapturerStats ReadStats() long _clockOffsetMs; // fallback when TimeUtils is not found: wall clock minus SystemClock.elapsedRealtime IntPtr _elapsedClass, _elapsedMethod; readonly jvalue[] _args = new jvalue[7]; - readonly jvalue[] _putArgs = new jvalue[1]; + readonly jvalue[] _readArgs = new jvalue[2]; readonly jvalue[] _noArgs = new jvalue[0]; bool _wasLaunched; bool _warnedMode; @@ -204,10 +212,7 @@ bool Begin(BugseeFrameCapturerOptions options) _bufferClear = AndroidJNI.GetMethodID(bufferCls, "clear", "()Ljava/nio/Buffer;"); _bufferRewind = AndroidJNI.GetMethodID(bufferCls, "rewind", "()Ljava/nio/Buffer;"); AndroidJNI.DeleteLocalRef(bufferCls); - IntPtr byteBufferCls = AndroidJNI.FindClass("java/nio/ByteBuffer"); - _bufferPut = AndroidJNI.GetMethodID(byteBufferCls, "put", "([B)Ljava/nio/ByteBuffer;"); - AndroidJNI.DeleteLocalRef(byteBufferCls); - if (_bufferClear == IntPtr.Zero || _bufferRewind == IntPtr.Zero || _bufferPut == IntPtr.Zero + if (_bufferClear == IntPtr.Zero || _bufferRewind == IntPtr.Zero || _addFrameMethod == IntPtr.Zero || _topDown == IntPtr.Zero || _bottomUp == IntPtr.Zero) { // On an SDK older than 7.4.1 the lookups above leave a pending NoSuchMethodError/ClassNotFoundException. @@ -217,6 +222,7 @@ bool Begin(BugseeFrameCapturerOptions options) return false; } InitClock(); + InitFrameFile(); } catch (Exception ex) { @@ -280,6 +286,31 @@ long NowMs() => _timeMethod != IntPtr.Zero ? AndroidJNI.CallStaticLongMethod(_timeClass, _timeMethod, _noArgs) : _clockOffsetMs + AndroidJNI.CallStaticLongMethod(_elapsedClass, _elapsedMethod, _noArgs); + // The private cache file and the Java FileChannel that reads it into the direct buffer. + void InitFrameFile() + { + _framePath = Path.Combine(Application.temporaryCachePath, "bugsee_frame.raw"); + _frameFile = new FileStream(_framePath, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite, 1); + IntPtr rafClass = AndroidJNI.FindClass("java/io/RandomAccessFile"); + IntPtr ctor = AndroidJNI.GetMethodID(rafClass, "", "(Ljava/lang/String;Ljava/lang/String;)V"); + var ctorArgs = new jvalue[2]; + ctorArgs[0].l = AndroidJNI.NewStringUTF(_framePath); + ctorArgs[1].l = AndroidJNI.NewStringUTF("r"); + IntPtr raf = AndroidJNI.NewObject(rafClass, ctor, ctorArgs); + AndroidJNI.DeleteLocalRef(ctorArgs[0].l); + AndroidJNI.DeleteLocalRef(ctorArgs[1].l); + AndroidJNI.DeleteLocalRef(rafClass); + if (raf == IntPtr.Zero) throw new IOException("RandomAccessFile open failed"); + IntPtr getChannel = AndroidJNI.GetMethodID(AndroidJNI.GetObjectClass(raf), "getChannel", "()Ljava/nio/channels/FileChannel;"); + IntPtr channel = AndroidJNI.CallObjectMethod(raf, getChannel, _noArgs); + AndroidJNI.DeleteLocalRef(raf); + _channel = AndroidJNI.NewGlobalRef(channel); + _channelRead = AndroidJNI.GetMethodID(AndroidJNI.GetObjectClass(channel), "read", "(Ljava/nio/ByteBuffer;J)I"); + AndroidJNI.DeleteLocalRef(channel); + if (_channel == IntPtr.Zero || _channelRead == IntPtr.Zero || ClearPendingException()) + throw new IOException("FileChannel setup failed"); + } + void Stop() { _enabled = false; @@ -299,7 +330,8 @@ void Update() var s = ReadStats(); Debug.Log("[BugseeFrameCapturer] requested=" + s.Requested + " pushed=" + s.Pushed + " failed=" + s.Failed + " droppedInFlight=" + s.DroppedInFlight + " droppedBlackoutOrStopped=" + s.DroppedBlackoutOrStopped - + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder + (_rowOrderVerified ? "" : " (unverified)")); + + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder + (_rowOrderVerified ? "" : " (unverified)") + " api=" + SystemInfo.graphicsDeviceType + + " avgMs[readbackLatency/copy/addFrame]=" + AvgMs(_latencySum) + "/" + AvgMs(_copySum) + "/" + AvgMs(_addSum)); } if (_policy.IsDisabled) _enabled = false; return; @@ -360,6 +392,7 @@ void Tick() UpdateNativeSize(); if (!EnsureBuffers()) { _policy.Complete(false); return; } _requestTimestampMs = NowMs(); + _requestTicks = System.Diagnostics.Stopwatch.GetTimestamp(); _requestNativeW = _nativeW; _requestNativeH = _nativeH; _requestRectCount = SecureRectRegistry.Instance.CopyRects(0, ref _requestRects); @@ -464,6 +497,7 @@ void OnReadback(AsyncGPUReadbackRequest request) _policy.Complete(false); return; } + _latencySum += System.Diagnostics.Stopwatch.GetTimestamp() - _requestTicks; NativeArray data = request.GetData(); if (data.Length < _pixels.Length) { _policy.Complete(false); return; } NativeArray.Copy(data, _pixels, _pixels.Length); @@ -492,6 +526,12 @@ void OnReadback(AsyncGPUReadbackRequest request) _signal.Release(); } + string AvgMs(long ticks) + { + long n = _timedFrames; + return n > 0 ? (ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / n).ToString("F1") : "-"; + } + // Colour of the centre pixel of the first and last buffer rows, to tell which row is the screen's top. void LogProbe() { @@ -529,19 +569,18 @@ bool PushOne() { try { - IntPtr java = AndroidJNI.ToByteArray(_pixels); - try - { - _putArgs[0].l = java; - AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferClear, _noArgs)); - AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferPut, _putArgs)); - if (ClearPendingException()) return false; - AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferRewind, _noArgs)); - } - finally - { - AndroidJNI.DeleteLocalRef(java); - } + long t0 = System.Diagnostics.Stopwatch.GetTimestamp(); + int length = _pushW * _pushH * FramePushPolicy.PixelStride; + _frameFile.Seek(0, SeekOrigin.Begin); + _frameFile.Write(_pixels, 0, length); + _frameFile.Flush(); + AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferClear, _noArgs)); + _readArgs[0].l = _directBuffer; + _readArgs[1].j = 0; + int read = AndroidJNI.CallIntMethod(_channel, _channelRead, _readArgs); + if (ClearPendingException() || read != length) return false; + AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferRewind, _noArgs)); + long t1 = System.Diagnostics.Stopwatch.GetTimestamp(); _args[0].j = _requestTimestampMs; _args[1].i = 0; // display id _args[2].l = _directBuffer; @@ -550,6 +589,10 @@ bool PushOne() _args[5].i = FramePushPolicy.PixelStride; _args[6].l = _pushOrder == VideoFrameRowOrder.BottomUp ? _bottomUp : _topDown; AndroidJNI.CallVoidMethod(_consumer, _addFrameMethod, _args); + long t2 = System.Diagnostics.Stopwatch.GetTimestamp(); + _copySum += t1 - t0; + _addSum += t2 - t1; + _timedFrames++; return !ClearPendingException(); } catch (Exception) @@ -587,12 +630,18 @@ void ReleaseNative() ReleaseDirectBuffer(); lock (_nativeLock) { - IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass }; + IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass, _channel }; for (int i = 0; i < refs.Length; i++) if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); - _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = IntPtr.Zero; + _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = _channel = IntPtr.Zero; _timeMethod = _elapsedMethod = IntPtr.Zero; } + if (_frameFile != null) + { + _frameFile.Dispose(); + _frameFile = null; + try { File.Delete(_framePath); } catch (Exception) { } + } _pixels = null; } diff --git a/Runtime/WrapperPolicy/FramePushPolicy.cs b/Runtime/WrapperPolicy/FramePushPolicy.cs index 3d269e0..d76a1a5 100644 --- a/Runtime/WrapperPolicy/FramePushPolicy.cs +++ b/Runtime/WrapperPolicy/FramePushPolicy.cs @@ -167,6 +167,8 @@ public static bool TryMeasuredRowOrder(string graphicsApi, out VideoFrameRowOrde { // Measured on an Android 16 arm64 emulator, Unity 2021.3.45: the first buffer row was the screen's bottom. case "OpenGLES3": order = VideoFrameRowOrder.BottomUp; return true; + // Samsung Galaxy Tab S5e (Adreno 615, Android 11), Unity 2021.3.45: the first buffer row was the screen's top. + case "Vulkan": order = VideoFrameRowOrder.TopDown; return true; default: return false; } } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index d3c4b96..b72c2a7 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -134,10 +134,11 @@ public void LaunchSdk() global::Bugsee.Capture.BugseeFrameCapturer.Enable( new global::Bugsee.Capture.BugseeFrameCapturerOptions { LogOrientationProbe = pattern }); // "bugsee_secure_rect" = "l,t,r,b" as fractions of the screen (top-left origin); it is removed after - // "bugsee_secure_rect_remove_s" seconds when that is set. For checking the mask in the recorded video. + // "bugsee_secure_rect_remove_s" seconds when that is set; "bugsee_secure_rect_delay_s" (default 2) is the wait before adding it. For checking the mask in the recorded video. var rect = ReadE2EValue("bugsee_secure_rect", "BUGSEE_SECURE_RECT"); if (!string.IsNullOrEmpty(rect)) - StartCoroutine(SecureRectDemo(rect, ReadE2EValue("bugsee_secure_rect_remove_s", "BUGSEE_SECURE_RECT_REMOVE_S"))); + StartCoroutine(SecureRectDemo(rect, ReadE2EValue("bugsee_secure_rect_remove_s", "BUGSEE_SECURE_RECT_REMOVE_S"), + ReadE2EValue("bugsee_secure_rect_delay_s", "BUGSEE_SECURE_RECT_DELAY_S"))); } #elif UNITY_IOS && !UNITY_EDITOR var iosOptions = new IOSLaunchOptions @@ -371,9 +372,10 @@ void FireProcessFailure(string scenario) } } - IEnumerator SecureRectDemo(string fractions, string removeAfterSeconds) + IEnumerator SecureRectDemo(string fractions, string removeAfterSeconds, string delaySeconds) { - yield return new WaitForSeconds(2f); + float.TryParse(delaySeconds, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var delay); + yield return new WaitForSeconds(delay > 0f ? delay : 2f); var p = fractions.Split(','); if (p.Length != 4) yield break; var inv = System.Globalization.CultureInfo.InvariantCulture; diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index a54a718..1a05e5d 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -140,7 +140,6 @@ public void Measured_gles3_is_bottom_up_and_verified() Assert.That(verified, Is.True); } - [TestCase("Vulkan")] [TestCase("OpenGLES2")] [TestCase("")] [TestCase(null)] @@ -150,10 +149,17 @@ public void Unmeasured_apis_are_unverified(string api) Assert.That(verified, Is.False); } + [Test] + public void Measured_vulkan_is_top_down_and_verified() + { + Assert.That(FramePushPolicy.ResolveRowOrder("Vulkan", null, out bool verified), Is.EqualTo(VideoFrameRowOrder.TopDown)); + Assert.That(verified, Is.True); + } + [Test] public void An_explicit_override_wins_and_counts_as_verified() { - Assert.That(FramePushPolicy.ResolveRowOrder("Vulkan", VideoFrameRowOrder.TopDown, out bool verified), Is.EqualTo(VideoFrameRowOrder.TopDown)); + Assert.That(FramePushPolicy.ResolveRowOrder("OpenGLES2", VideoFrameRowOrder.TopDown, out bool verified), Is.EqualTo(VideoFrameRowOrder.TopDown)); Assert.That(verified, Is.True); Assert.That(FramePushPolicy.ResolveRowOrder("OpenGLES3", VideoFrameRowOrder.TopDown, out _), Is.EqualTo(VideoFrameRowOrder.TopDown)); } From 7db415df36fda96ed6fa225ab584c328e245fcb6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:10:15 +0500 Subject: [PATCH 125/157] Attach thread-pool threads for Android span JNI calls; add APM e2e scenarios. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified on a Galaxy Tab A 8.0 (Android 11): a span call from a thread-pool thread aborted the process (CheckJNI) without an attach. AndroidJniScope attaches and detaches foreign threads around every span JNI entry. The sample gains apm, apmoff and apmleak scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 2 +- Runtime/Contracts/Apm/ISpan.cs | 2 +- Runtime/Platform/Android/AndroidBridge.cs | 8 +- Runtime/Platform/Android/AndroidJniScope.cs | 43 +++++ .../Platform/Android/AndroidJniScope.cs.meta | 11 ++ Runtime/Platform/Android/AndroidSpanHandle.cs | 23 ++- .../Assets/Scripts/BugseeHarness/ApmE2E.cs | 153 ++++++++++++++++++ .../Scripts/BugseeHarness/ApmE2E.cs.meta | 11 ++ .../BugseeHarness/BugseeSampleBootstrap.cs | 17 ++ 9 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 Runtime/Platform/Android/AndroidJniScope.cs create mode 100644 Runtime/Platform/Android/AndroidJniScope.cs.meta create mode 100644 Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs create mode 100644 Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs.meta diff --git a/Documentation~/index.md b/Documentation~/index.md index 92bc772..371ba69 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -239,6 +239,6 @@ using (var pay = checkout.StartChild("payment", "charge card")) `Bugsee.StartTransaction(name, operation, attributes = null)` returns an `ISpan` (`SpanId`, `TraceId`, `Operation`, `IsFinished`, `SetName`, `SetDescription`, `SetAttribute`, `SetStatus`, `StartChild(operation, description = null)`, `Finish()` / `Finish(SpanStatus)`, `Dispose` = `Finish()`). Attribute values follow the `SetAttribute` rules; a rejected value is dropped with a Unity warning (spans never throw). The statuses are `Ok`, `Error`, `Timeout`, `Cancelled`, `DeadlineExceeded` and `Unknown`. -A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids, reads as finished after `Finish`) while the SDK is not launched or when the name or operation is empty. With performance monitoring off, both native SDKs return their own shared no-op span (ids read `"0"`), which is wrapped like any other span. No call on a span throws: invalid input and native failures log a Unity warning. If a child finishes on another thread while its parent finishes, the status that reached the child first wins. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). Android threading of a span is not documented by the native SDK, so use a span from the thread that created it unless you serialize access yourself. +A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids, reads as finished after `Finish`) while the SDK is not launched or when the name or operation is empty. With performance monitoring off, iOS returns its shared no-op span (ids read `"0"`), which is wrapped like any other span; on Android (verified on a Galaxy Tab A 8.0, Android 11) the call returns the inert span with empty ids. No call on a span throws: invalid input and native failures log a Unity warning. If a child finishes on another thread while its parent finishes, the status that reached the child first wins. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). A span can be used from any thread, including thread-pool and `Task` threads: on Android each JNI entry attaches a thread Unity did not create for the duration of the call and detaches it again (without it the JVM aborts the process on such a thread), and on iOS the handle is an id. A parent finished on one thread while a child finishes on another is safe. Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). diff --git a/Runtime/Contracts/Apm/ISpan.cs b/Runtime/Contracts/Apm/ISpan.cs index eba328b..a22e0cf 100644 --- a/Runtime/Contracts/Apm/ISpan.cs +++ b/Runtime/Contracts/Apm/ISpan.cs @@ -12,7 +12,7 @@ namespace Bugsee.Contracts.Apm /// public interface ISpan : IDisposable { - /// The native span id ("0" for the native no-op span used when performance monitoring is off), or an empty string for an inert span. + /// The native span id ("0" for the iOS no-op span used when performance monitoring is off), or an empty string for an inert span. string SpanId { get; } /// The id of the trace the span belongs to, or an empty string for an inert span. diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index ca377b0..6dcf1bd 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -115,7 +115,12 @@ void Stop(Action completion, Action finished) }); } - public bool GetLaunched() => _bugsee.CallStatic("getLaunched"); + public bool GetLaunched() + { + // Read before every StartTransaction, which may run on a thread-pool thread (see AndroidJniScope). + using (AndroidJniScope.Enter()) + return _bugsee.CallStatic("getLaunched"); + } public IReadOnlyDictionary GetLaunchOptions() { @@ -358,6 +363,7 @@ public void Notify(string title, string body, IssueSeverity severity, IDictionar public ISpanHandle StartTransaction(string name, string operation, IDictionary attributes) { // The three-argument overload always takes a map (a null cannot select an overload), empty when there are none. + using (AndroidJniScope.Enter()) using (var javaAttributes = AndroidJavaConverters.ObjectDictionaryToMap(attributes)) { var transaction = _bugsee.CallStatic("startTransaction", name, operation, javaAttributes); diff --git a/Runtime/Platform/Android/AndroidJniScope.cs b/Runtime/Platform/Android/AndroidJniScope.cs new file mode 100644 index 0000000..5326f83 --- /dev/null +++ b/Runtime/Platform/Android/AndroidJniScope.cs @@ -0,0 +1,43 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using System.Threading; +using UnityEngine; + +namespace Bugsee.Platform.Android +{ + /// + /// Makes a JNI call safe from a thread Unity did not create (a thread-pool or Task thread). Such a thread + /// is not attached to the JVM, and a JNI call from it aborts the process (CheckJNI: "use of deleted local + /// reference"; seen on a Galaxy Tab A 8.0 / Android 11). Wrap every JNI entry that spans can reach: on a + /// foreign thread the outermost scope attaches the thread and detaches it again, on the main thread (and nested + /// scopes) it does nothing, so a thread Unity attached itself is never detached. + /// + struct AndroidJniScope : IDisposable + { + static int _mainThreadId = -1; + + [ThreadStatic] static int _depth; + + readonly bool _attached; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void CaptureMainThread() => _mainThreadId = Thread.CurrentThread.ManagedThreadId; + + public static AndroidJniScope Enter() + { + if (_depth++ > 0 || Thread.CurrentThread.ManagedThreadId == _mainThreadId) + return new AndroidJniScope(false); + AndroidJNI.AttachCurrentThread(); + return new AndroidJniScope(true); + } + + AndroidJniScope(bool attached) => _attached = attached; + + public void Dispose() + { + _depth--; + if (_attached) AndroidJNI.DetachCurrentThread(); + } + } +} +#endif diff --git a/Runtime/Platform/Android/AndroidJniScope.cs.meta b/Runtime/Platform/Android/AndroidJniScope.cs.meta new file mode 100644 index 0000000..f847c7d --- /dev/null +++ b/Runtime/Platform/Android/AndroidJniScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e1e0b16ecb3f4c8893fb502efcd5ee54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Platform/Android/AndroidSpanHandle.cs b/Runtime/Platform/Android/AndroidSpanHandle.cs index bd4de18..d43e0a7 100644 --- a/Runtime/Platform/Android/AndroidSpanHandle.cs +++ b/Runtime/Platform/Android/AndroidSpanHandle.cs @@ -19,6 +19,7 @@ sealed class AndroidSpanHandle : ISpanHandle public AndroidSpanHandle(AndroidJavaObject span) { _span = span ?? throw new ArgumentNullException(nameof(span)); + using (AndroidJniScope.Enter()) try { SpanId = span.Call("getSpanId") ?? ""; @@ -34,46 +35,56 @@ public AndroidSpanHandle(AndroidJavaObject span) public string SpanId { get; } public string TraceId { get; } + // Every entry below runs inside an AndroidJniScope: a span may be used from a thread-pool thread, which the + // JVM does not know until it is attached (see AndroidJniScope). public void SetName(string name) => Chain("setName", name); public void SetDescription(string description) => Chain("setDescription", description); public void SetAttribute(string key, object value) { + using (AndroidJniScope.Enter()) using (var boxed = AndroidJavaConverters.Box(value)) Chain("setAttribute", key, boxed); } public void SetStatus(SpanStatus status) { + using (AndroidJniScope.Enter()) using (var native = NativeStatus(status)) Chain("setStatus", native); } public ISpanHandle StartChild(string operation, string description) { - // A null cannot select a JNI overload, so no description uses the one-argument method. - var child = description == null - ? _span.Call("startChildSpan", operation) - : _span.Call("startChildSpan", operation, description); - return child == null ? null : new AndroidSpanHandle(child); + using (AndroidJniScope.Enter()) + { + // A null cannot select a JNI overload, so no description uses the one-argument method. + var child = description == null + ? _span.Call("startChildSpan", operation) + : _span.Call("startChildSpan", operation, description); + return child == null ? null : new AndroidSpanHandle(child); + } } public void Finish(SpanStatus status) { + using (AndroidJniScope.Enter()) using (var native = NativeStatus(status)) _span.Call("finish", native); } public void Release() { - _span?.Dispose(); + using (AndroidJniScope.Enter()) + _span?.Dispose(); _span = null; } // The setters return the span for chaining; each call returns a new reference that must be disposed. void Chain(string method, params object[] args) { + using (AndroidJniScope.Enter()) using (_span.Call(method, args)) { } } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs new file mode 100644 index 0000000..931cc11 --- /dev/null +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Bugsee.Contracts.Apm; +using UnityEngine; + +namespace Bugsee.Sample +{ + /// + /// APM e2e (scenarios "apm" and "apmoff"): drives transactions and spans from the main thread and from thread-pool + /// threads, with odd inputs, and logs every id with the "[ApmE2E]" tag so a run can be matched to what the backend + /// received. "apmoff" runs the same calls with performance monitoring off and then a long create/finish loop that would + /// overflow the JNI global reference table if a span leaked its reference. + /// + static class ApmE2E + { + static void Log(string message) => Debug.Log("[ApmE2E] " + message); + + static string Describe(ISpan span) => + "span=" + span.SpanId + " trace=" + span.TraceId + " op=" + span.Operation + " finished=" + span.IsFinished; + + static T Try(string what, Func call) + { + try + { + return call(); + } + catch (Exception e) + { + Log("THREW " + what + ": " + e.GetType().Name + ": " + e.Message); + return default(T); + } + } + + public static void Run(string runId, bool monitoringOff, bool leak = false) + { + Log("begin run=" + runId + " monitoringOff=" + monitoringOff + " thread=" + Thread.CurrentThread.ManagedThreadId); + if (leak) + { + Loop(runId, 54000); + Log("end run=" + runId); + return; + } + MainThread(runId); + PoolThreads(runId); + OddInputs(runId); + Loop(runId, monitoringOff ? 54000 : 1500); + Log("end run=" + runId); + } + + static void MainThread(string runId) + { + var txn = Bugsee.StartTransaction("apm-main-" + runId, "e2e.main", new Dictionary + { + ["t_double"] = 1.0, ["t_int"] = 7, ["t_string"] = "x", ["t_bool"] = true + }); + Log("main txn " + Describe(txn)); + var db = txn.StartChild("db.query", "select 1"); + Log("main child " + Describe(db)); + db.SetAttribute("c_double", 2.5).SetAttribute("c_int", 42L).SetAttribute("c_string", "abc").SetAttribute("c_bool", false); + db.SetName("db-renamed").SetDescription("select 2"); + var inner = db.StartChild("db.row"); + Log("main grandchild " + Describe(inner)); + inner.Finish(SpanStatus.Timeout); + db.SetStatus(SpanStatus.Error); + db.Finish(SpanStatus.DeadlineExceeded); + var open = txn.StartChild("never.finished", "cancelled by parent"); + Log("main open child " + Describe(open)); + txn.SetStatus(SpanStatus.Ok); + txn.Finish(); + Log("main after finish txn=" + txn.IsFinished + " open=" + open.IsFinished); + } + + static void PoolThreads(string runId) + { + // Everything on one pool thread: create, child, attributes, finish. + Task.Run(() => + { + Log("pool-all thread=" + Thread.CurrentThread.ManagedThreadId + " isPool=" + Thread.CurrentThread.IsThreadPoolThread); + var txn = Try("pool StartTransaction", () => Bugsee.StartTransaction("apm-pool-" + runId, "e2e.pool")); + if (txn == null) return; + Log("pool txn " + Describe(txn)); + var c = txn.StartChild("pool.child", "from pool"); + c.SetAttribute("p_double", 3.25).SetAttribute("p_int", 5); + c.Finish(); + txn.Finish(SpanStatus.Ok); + Log("pool done txn=" + txn.IsFinished + " child=" + c.IsFinished); + }).Wait(); + + // Created on main, children and finish on pool threads, concurrently with the parent finishing. + var shared = Bugsee.StartTransaction("apm-cross-" + runId, "e2e.cross"); + Log("cross txn " + Describe(shared)); + var kids = new ISpan[20]; + for (int i = 0; i < kids.Length; i++) kids[i] = shared.StartChild("cross.child." + i); + var tasks = new Task[kids.Length]; + for (int i = 0; i < kids.Length; i++) + { + var k = kids[i]; + tasks[i] = Task.Run(() => { k.SetAttribute("n", 1); k.Finish(SpanStatus.Error); }); + } + shared.Finish(SpanStatus.Ok); + Task.WaitAll(tasks); + int finished = 0; + foreach (var k in kids) if (k.IsFinished) finished++; + Log("cross done finishedKids=" + finished + "/" + kids.Length + " parent=" + shared.IsFinished); + } + + static void OddInputs(string runId) + { + Log("odd empty name -> " + Describe(Bugsee.StartTransaction("", "op"))); + Log("odd null op -> " + Describe(Bugsee.StartTransaction("n", null))); + var txn = Bugsee.StartTransaction("apm-odd-" + runId, "e2e.odd", new Dictionary + { + ["ok"] = "fine", ["bad_obj"] = new object(), ["bad_nan"] = double.NaN, ["bad_big"] = ulong.MaxValue, [""] = "empty-key" + }); + Log("odd txn " + Describe(txn)); + txn.SetAttribute("long", new string('x', 2000)).SetAttribute(null, 1).SetAttribute("k", null); + txn.SetName(null).SetName(""); + txn.SetDescription(null); + txn.SetStatus((SpanStatus)99); + Log("odd empty child -> " + Describe(txn.StartChild(""))); + txn.Finish((SpanStatus)99); + txn.Finish(); + txn.Dispose(); + Log("odd after finish child -> " + Describe(txn.StartChild("late")) + " txnFinished=" + txn.IsFinished); + + ISpan disposed; + using (disposed = Bugsee.StartTransaction("apm-dispose-" + runId, "e2e.dispose")) + { + disposed.SetAttribute("in_using", true); + disposed.StartChild("dispose.child"); + } + Log("odd dispose without finish finished=" + disposed.IsFinished); + } + + static void Loop(string runId, int count) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var txn = Bugsee.StartTransaction("apm-loop-" + runId, "e2e.loop"); + for (int i = 0; i < count; i++) + { + var c = txn.StartChild("loop.child"); + c.SetAttribute("i", i); + c.Finish(); + } + txn.Finish(); + for (int i = 0; i < count / 2; i++) + Bugsee.StartTransaction("apm-loop-txn", "e2e.loop").Dispose(); + Log("loop done count=" + count + " ms=" + sw.ElapsedMilliseconds); + } + } +} diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs.meta b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs.meta new file mode 100644 index 0000000..3400fd8 --- /dev/null +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 933187d669914f9291107df3687db419 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 02c6f89..11102d9 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -118,6 +118,8 @@ public void LaunchSdk() DetectAndReportHang = true, DetectAndReportExitNotResponding = true, DetectAndReportExitLowMemory = true, + PerformanceMonitoring = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") != "apmoff", + PerformanceSampleRate = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmleak" ? 0f : 1f, Debug = true }; if (!string.IsNullOrWhiteSpace(endpointOverride)) @@ -258,6 +260,15 @@ void Update() _pendingE2EFire = "L:e2e-upload-" + _e2eRunId; } break; + case "apm": + case "apmoff": + case "apmleak": + if (_e2ePhase == 0 && _e2eTimer >= 5f) + { + _e2ePhase = 99; + _pendingE2EFire = "M:" + _e2eRunId; + } + break; case "none": // Recovery launch: arm nothing, only let the SDK recover and upload what the last run left. break; @@ -313,6 +324,12 @@ void PumpPendingE2EFire(string pending) case 'K': FireProcessFailure(marker); break; + case 'M': + SetStatus("E2E APM " + marker); + ApmE2E.Run(marker, _e2eScenario == "apmoff", _e2eScenario == "apmleak"); + // The report bundle carries every transaction, which is what the backend check reads back. + if (_e2eScenario == "apm") Bugsee.Upload("apm-e2e-" + marker, "APM e2e run " + marker); + break; case 'L': SetStatus("E2E manual upload " + marker); Bugsee.Upload(marker, "uploaded by the e2e harness"); From 15bad08150df0b14b9ff40f1d33974549860ea7e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:21:36 +0500 Subject: [PATCH 126/157] Frame pusher: Unity 2022.2+ only, native-memory path, wrapper falls back to the default capture. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the cache-file path. AndroidJNI.NewDirectByteBuffer exists from Unity 2022.2.0b1; older Unity makes Enable() return false. A launch that asks for DirectBuffers starts the pusher itself and, when it cannot run (old Unity, no Screen Capture module, no AsyncGPUReadback, unmeasured graphics API, start failure), the wrapper replaces DirectBuffers with the default capture before the native SDK launches. UnityCapturePolicy holds the decision. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 21 +- Runtime/Bugsee.Runtime.asmdef | 7 +- Runtime/Bugsee.cs | 39 ++- Runtime/Capture/BugseeFrameCapturer.cs | 250 ++++++++---------- Runtime/Internal/UnityCaptureProbe.cs | 26 ++ Runtime/Internal/UnityCaptureProbe.cs.meta | 11 + Runtime/WrapperPolicy/FrameRedaction.cs | 17 +- Runtime/WrapperPolicy/UnityCapturePolicy.cs | 110 ++++++++ .../WrapperPolicy/UnityCapturePolicy.cs.meta | 11 + .../BugseeHarness/BugseeSampleBootstrap.cs | 69 +++-- .../Scripts/BugseeHarness/FrameTimeLogger.cs | 32 +++ .../BugseeHarness/FrameTimeLogger.cs.meta | 11 + .../OrientationPatternOverlay.cs | 6 + .../Bugsee.WrapperPolicy.Tests.csproj | 1 + Tests~/WrapperPolicy/FramePushPolicyTests.cs | 39 +++ .../WrapperPolicy/UnityCapturePolicyTests.cs | 197 ++++++++++++++ .../Runtime/Bugsee.Runtime.csproj | 1 + 18 files changed, 672 insertions(+), 178 deletions(-) create mode 100644 Runtime/Internal/UnityCaptureProbe.cs create mode 100644 Runtime/Internal/UnityCaptureProbe.cs.meta create mode 100644 Runtime/WrapperPolicy/UnityCapturePolicy.cs create mode 100644 Runtime/WrapperPolicy/UnityCapturePolicy.cs.meta create mode 100644 Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs create mode 100644 Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs.meta create mode 100644 Tests~/WrapperPolicy/UnityCapturePolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cd1cb..d563eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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`, reused buffers and a private cache file as the hand-over to Java, one request in flight, frames dropped meanwhile, throttled to `TargetFps`, default 5). It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (the SDK does not mask them); blackout is honoured by the SDK. Readback failures turn it off (no synchronous fallback), and Vulkan needs Unity 2021.3.18 or newer. **Not benchmarked on devices.** The readback row order was measured for OpenGLES3 (emulator) and Vulkan (Galaxy Tab S5e); on any other graphics API it is unverified and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Pixelation and secure views are not applied to pushed frames. Needs the `com.unity.modules.screencapture` module (compiled out without it). Nothing changes unless you call `Enable()`: the default capture stays the SDK's own (`VideoMode.V2`), and a launch with `DirectBuffers` but no `Enable()` gets no frames from Unity (nothing pushes them). Refs #22 (benchmarking V2 against this on real devices is outstanding). +- 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, no file, no disk writes), one request in flight with frames dropped meanwhile, throttled to `TargetFps` (default 5). **Supported on Unity 2022.2 and newer (2022.3 LTS, 6000.x); not on 2021.3 or 2022.1**, which lack `AndroidJNI.NewDirectByteBuffer`: 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/Vulkan, a start failure) the wrapper replaces `DirectBuffers` with the default capture (`VideoMode.V2`) before the native SDK launches, with one warning; 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 OpenGLES3 (emulator) and Vulkan (Galaxy Tab S5e); on another graphics API it is unverified and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index 3894dfd..745a663 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -232,26 +232,27 @@ Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/ ## Experimental: Unity frame pusher (Android) -Not benchmarked on devices; do not enable it in production without measuring. Nothing here changes unless you opt in: by default the Android SDK captures the window itself (`VideoMode.V2`), and `BugseeFrameCapturer` does not exist at runtime until you call `Enable()`. +Not benchmarked beyond one tablet; do not enable it in production without measuring. Nothing here changes unless you opt in: by default the Android SDK captures the window itself (`VideoMode.V2`), and the capturer only runs for a launch that asks for `VideoMode.DirectBuffers` (or an explicit `Enable()` call). + +**Unity versions.** Supported: Unity 2022.2.0b1 and newer (that is the first version with `AndroidJNI.NewDirectByteBuffer`, checked against Unity's reference source: 2022.2.0b1, all of 2022.3 LTS and 6000.x have it; 2022.2.0a1, 2022.1 and 2021.3 do not), Android player builds, with the Screen Capture module. **Not supported: Unity 2021.3 and 2022.1.** The only alternatives there (a Java `byte[]` per frame, or a file) were measured and rejected (about 0.9 s per 1 MB frame, or disk writes). The minimum is one constant, `UnityCapturePolicy.MinimumUnityVersion`, plus the `BUGSEE_UNITY_CAPTURE` version define in `Bugsee.Runtime.asmdef`. + +**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: Unity older than 2022.2.0b1, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch (then the wrapper relaunches with the default capture). Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. ```csharp // using Bugsee.Capture; #if UNITY_ANDROID && !UNITY_EDITOR +BugseeFrameCapturer.DefaultOptions = new BugseeFrameCapturerOptions { TargetFps = 5, MaxDimension = 640 }; // optional Bugsee.Launch(token, new AndroidLaunchOptions { CaptureVideo = true, CaptureVideoMode = VideoMode.DirectBuffers }); -BugseeFrameCapturer.Enable(new BugseeFrameCapturerOptions { TargetFps = 5, MaxDimension = 640 }); #endif ``` -How it works: at the end of each due frame it calls `ScreenCapture.CaptureScreenshotIntoRenderTexture`, scales into a small RenderTexture and reads it back with `AsyncGPUReadback` (never a synchronous read). One request is in flight at a time; frames that come due meanwhile are dropped, not queued. Buffers are reused; the pixels reach the SDK's `VideoFrameConsumer.addFrame` from a background thread with the capture time as the timestamp. `BugseeFrameCapturer.Stats` and a log line every 10 s show requested, pushed, failed and dropped counts. +How it works: at the end of each due frame it calls `ScreenCapture.CaptureScreenshotIntoRenderTexture`, scales into a small RenderTexture and reads it back with `AsyncGPUReadback` (never a synchronous read). One request is in flight at a time; frames that come due meanwhile are dropped, not queued. The readback is copied once into a persistent native buffer, secure rectangles are blanked in place, and a push thread wraps that memory as a Java direct `ByteBuffer` for `VideoFrameConsumer.addFrame` with the SDK's own clock as the timestamp. No Java array and no file: frames never touch disk. `BugseeFrameCapturer.Stats` and a log line every 10 s show requested, pushed, failed and dropped counts. What to know before using it: -- Needs the Screen Capture built-in module (`com.unity.modules.screencapture`, on by default in new projects); without it the capturer is compiled out and `Enable()` returns false. Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. +- Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. - It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. -- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (the activity window size, top-left origin) and scaled to the frame. Secure native views are not known to it. Blackout is honoured by the SDK. -- Row order of the readback was measured for OpenGLES3 (Android 16 arm64 emulator: first buffer row is the screen bottom, BottomUp) and Vulkan (Galaxy Tab S5e, Android 11, Adreno 615: first row is the screen top, TopDown; the decoded report video showed red on top for a red-over-blue test pattern). For any other graphics API (OpenGLES2) it is unverified, and the pusher fails closed: while a secure rectangle is registered, every pushed frame is fully black, and a warning is logged. Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. -- Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (crash in older builds). After 5 failed readbacks in a row it turns itself off. -- Unity 2021.3's `AndroidJNI` cannot expose native memory as a `ByteBuffer` (later Unity versions can; not used here). `AndroidJNI.ToByteArray` allocates a Java array per frame and measured about 0.9 s per 1 MB on the tablet (1 frame/s), so each frame is written to a private cache file (`Application.temporaryCachePath/bugsee_frame.raw`, already redacted, deleted on disable) and read by `FileChannel.read` into a reused direct buffer: about 2 ms per 1 MB frame. The frame therefore touches app-private storage briefly. Lower `MaxDimension` or `TargetFps` to reduce the cost. Secure rectangles are copied into reused buffers under a lock, so that part does not allocate per frame. -- Frames are stamped with the SDK's own clock (`TimeUtils.monotonicWallTimeMillis`, falling back to boot time plus `SystemClock.elapsedRealtime`). +- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (the activity window size, top-left origin) and scaled to the frame. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. +- Row order of the readback was measured for OpenGLES3 (Android 16 arm64 emulator: BottomUp) and Vulkan (Galaxy Tab S5e, Android 11, Adreno 615: TopDown). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. +- Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (not reachable on supported versions). After 5 failed readbacks in a row it turns itself off. - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. -- Measured on a Galaxy Tab S5e (Android 11, Vulkan, IL2CPP development build, sample scene, 400x640 capture at `TargetFps` 5, about 2.5 minutes each): about 4.5 frames/s pushed, 2 in-flight drops, 0 failures; average readback latency 88 ms, file copy 2 ms, `addFrame` 7.5 ms. Compared with the pusher off, the Unity main thread stayed at about 45% of a core and the render thread at about 17-19%; the push thread added about 3-6%, and total PSS was unchanged (about 325 MB either way). This is one device and one light scene, not a benchmark of heavier games, and the surface-level frame rate with the pusher on versus off was not measured (`gfxinfo` does not see a Unity surface). diff --git a/Runtime/Bugsee.Runtime.asmdef b/Runtime/Bugsee.Runtime.asmdef index 90188cc..bca6034 100644 --- a/Runtime/Bugsee.Runtime.asmdef +++ b/Runtime/Bugsee.Runtime.asmdef @@ -4,7 +4,7 @@ "references": [], "includePlatforms": [], "excludePlatforms": [], - "allowUnsafeCode": false, + "allowUnsafeCode": true, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, @@ -14,6 +14,11 @@ "name": "com.unity.modules.screencapture", "expression": "", "define": "BUGSEE_HAS_SCREENCAPTURE" + }, + { + "name": "Unity", + "expression": "2022.2", + "define": "BUGSEE_UNITY_CAPTURE" } ], "noEngineReferences": false diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 0665a2c..14edc6d 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -65,7 +65,9 @@ public static void Launch(string appToken, Dictionary options = throw new ArgumentException("appToken is required", nameof(appToken)); MainThreadDispatcher.Ensure(); + options = ApplyCapturePolicy(options, out bool startPusher); Bridge.Launch(appToken, options); + AfterLaunch(startPusher, options); } /// @@ -73,8 +75,39 @@ public static void Launch(string appToken, Dictionary options = /// (the same 5 s main-thread hand-off applies on iOS). Android ignores it with a /// warning unless the SDK is Launched. See for the result. /// - public static void Relaunch(Dictionary options = null) => + public static void Relaunch(Dictionary options = null) + { + options = ApplyCapturePolicy(options, out bool startPusher); Bridge.Relaunch(options); + AfterLaunch(startPusher, options); + } + + // VideoMode.DirectBuffers needs a frame source, and in Unity that is the Unity-side capturer (Unity 2022.2+). When it + // cannot run, the native SDK is launched in its default capture mode instead, never in a DirectBuffers session + // without frames. See UnityCapturePolicy. + static Dictionary ApplyCapturePolicy(Dictionary options, out bool startPusher) + { + var effective = UnityCapturePolicy.Apply(options, UnityCaptureProbe.Current(), out VideoModeDecision decision); + startPusher = decision.UseUnityCapture; + if (decision.FallbackReason != null) + Debug.LogWarning("[Bugsee] VideoMode.DirectBuffers was requested, but Unity-side capture cannot run: " + + decision.FallbackReason + ". Launching with the SDK's default capture instead."); + return effective; + } + + // Starts the pusher for a DirectBuffers launch, and if it cannot start, relaunches with the default capture so the + // session still records video. Stops it when a launch no longer wants it. + static void AfterLaunch(bool startPusher, Dictionary options) + { + if (!startPusher) + { + Capture.BugseeFrameCapturer.Disable(); + return; + } + if (Capture.BugseeFrameCapturer.Enable(Capture.BugseeFrameCapturer.DefaultOptions)) return; + Debug.LogWarning("[Bugsee] the Unity-side frame pusher could not start; relaunching with the SDK's default capture."); + Bridge.Relaunch(UnityCapturePolicy.WithFallback(options)); + } /// /// Stop Bugsee. runs once the SDK has finished stopping. A Launch/Relaunch @@ -104,6 +137,7 @@ public static Task LaunchAsync(string appToken, Dictionary throw new ArgumentException("appToken is required", nameof(appToken)); MainThreadDispatcher.Ensure(); + options = ApplyCapturePolicy(options, out bool startPusher); var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { @@ -114,12 +148,14 @@ public static Task LaunchAsync(string appToken, Dictionary Bridge.Launch(appToken, options); result.TrySetResult(IsStartingOrLaunched(Bridge.GetStatus())); } + AfterLaunch(startPusher, options); return result.Task; } /// that completes with the SDK's answer, as . public static Task RelaunchAsync(Dictionary options = null) { + options = ApplyCapturePolicy(options, out bool startPusher); var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { @@ -130,6 +166,7 @@ public static Task RelaunchAsync(Dictionary options = null Bridge.Relaunch(options); result.TrySetResult(IsStartingOrLaunched(Bridge.GetStatus())); } + AfterLaunch(startPusher, options); return result.Task; } diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index 8271b8c..1218ebf 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -1,3 +1,9 @@ +// Unity-side capture (ScreenCapture + AsyncGPUReadback) handed to the SDK as a native-memory Java ByteBuffer. That needs +// AndroidJNI.NewDirectByteBuffer, which exists from Unity 2022.2.0b1 (all of 2022.3 LTS and 6000.x), not in 2021.3 or +// 2022.1: there the capturer is unsupported (Enable() returns false) and the SDK's own capture stays in effect. +#if BUGSEE_UNITY_CAPTURE && UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE +#define BUGSEE_READBACK +#endif using System; using System.Collections.Generic; using Bugsee.Contracts.Capture; @@ -5,8 +11,7 @@ using Bugsee.WrapperPolicy; using UnityEngine; -#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE -using System.IO; +#if BUGSEE_READBACK using System.Threading; using Unity.Collections; using UnityEngine.Rendering; @@ -54,54 +59,52 @@ public sealed class BugseeFrameCapturer : MonoBehaviour { static BugseeFrameCapturer _instance; - /// True once started the pusher on this platform and it has not been disabled. + /// True once started the pusher and it has not been disabled. public static bool IsEnabled => _instance != null && _instance._enabled; - public static BugseeFrameCapturerStats Stats => - _instance != null ? _instance.ReadStats() : default; + /// + /// Options the wrapper uses when it starts the pusher itself (a launch that asks for VideoMode.DirectBuffers). + /// Set before Bugsee.Launch. + /// + public static BugseeFrameCapturerOptions DefaultOptions = new BugseeFrameCapturerOptions(); + + public static BugseeFrameCapturerStats Stats => _instance != null ? _instance.ReadStats() : default; /// - /// Starts the pusher. Returns false (and does nothing) off Android, in the Editor, or when the device cannot do - /// GPU readback. Call after or around Bugsee.Launch with VideoMode.DirectBuffers; frames are skipped until the - /// SDK is launched. Main thread only. + /// Starts the pusher. Returns false (and does nothing) off Android, in the Editor, on Unity older than 2022.2 + /// (no AndroidJNI.NewDirectByteBuffer), without the Screen Capture module, or when the device cannot do GPU + /// readback. Call after or around Bugsee.Launch with VideoMode.DirectBuffers; frames are skipped until the SDK + /// is launched. Main thread only. /// public static bool Enable(BugseeFrameCapturerOptions options = null) { -#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE +#if BUGSEE_READBACK if (!Internal.MainThreadDispatcher.IsMainThread) { Internal.MainThreadDispatcher.Run(() => Enable(options)); return true; } - if (_instance != null && _instance._enabled) return true; - - bool vulkan = SystemInfo.graphicsDeviceType == GraphicsDeviceType.Vulkan; - if (!FramePushPolicy.IsReadbackUsable(SystemInfo.supportsAsyncGPUReadback, vulkan, Application.unityVersion)) - { - Debug.LogWarning("[BugseeFrameCapturer] GPU readback is not usable here (unsupported, or Vulkan on Unity older than 2021.3.18); not enabled."); - return false; - } - - var go = new GameObject("Bugsee.FrameCapturer") { hideFlags = HideFlags.HideAndDontSave }; - DontDestroyOnLoad(go); - var capturer = go.AddComponent(); - if (!capturer.Begin(options ?? new BugseeFrameCapturerOptions())) + if (IsEnabled) return true; + return EnableReadback(options ?? new BugseeFrameCapturerOptions()); +#else + if (!_warnedUnsupported) { - Destroy(go); - return false; + _warnedUnsupported = true; + Debug.LogWarning("[BugseeFrameCapturer] unsupported here: it needs an Android player build on Unity 2022.2 or newer " + + "(AndroidJNI.NewDirectByteBuffer) with the Screen Capture module. The SDK's own capture stays in effect."); } - _instance = capturer; - return true; -#else - Debug.LogWarning("[BugseeFrameCapturer] Android player builds with the Screen Capture module only; not enabled."); return false; #endif } +#if !BUGSEE_READBACK + static bool _warnedUnsupported; +#endif + /// Stops the pusher and releases its buffers once any push in flight has finished. public static void Disable() { -#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE +#if BUGSEE_READBACK if (!Internal.MainThreadDispatcher.IsMainThread) { Internal.MainThreadDispatcher.Run(Disable); @@ -126,7 +129,28 @@ BugseeFrameCapturerStats ReadStats() }; } -#if UNITY_ANDROID && !UNITY_EDITOR && BUGSEE_HAS_SCREENCAPTURE +#if BUGSEE_READBACK + static bool EnableReadback(BugseeFrameCapturerOptions options) + { + bool vulkan = SystemInfo.graphicsDeviceType == GraphicsDeviceType.Vulkan; + if (!FramePushPolicy.IsReadbackUsable(SystemInfo.supportsAsyncGPUReadback, vulkan, Application.unityVersion)) + { + Debug.LogWarning("[BugseeFrameCapturer] GPU readback is not usable here (unsupported, or Vulkan on Unity older than 2021.3.18); not enabled."); + return false; + } + + var go = new GameObject("Bugsee.FrameCapturer") { hideFlags = HideFlags.HideAndDontSave }; + DontDestroyOnLoad(go); + var capturer = go.AddComponent(); + if (!capturer.Begin(options)) + { + Destroy(go); + return false; + } + _instance = capturer; + return true; + } + const string ConsumerClass = "com/bugsee/library/contracts/capture/consumers/VideoFrameConsumer"; const string RowOrderClass = ConsumerClass + "$RowOrder"; const string AddFrameSignature = "(JILjava/nio/ByteBuffer;IIIL" + RowOrderClass + ";)V"; @@ -155,21 +179,17 @@ BugseeFrameCapturerStats ReadStats() int _probeFrames; // Timing for the stats line (average ms): request to readback callback, copy into the Java buffer, addFrame. - long _requestTicks, _latencySum, _copySum, _addSum, _timedFrames; - - // Pixels: the readback callback (main thread) fills _pixels, the push thread copies them into the Java - // direct buffer and calls addFrame. Unity 2021.3's AndroidJNI cannot wrap or address native memory as a - // ByteBuffer (later Unity versions can; not used here). AndroidJNI.ToByteArray allocates a Java array per - // frame and measured about 0.9 s per 1 MB on a Galaxy Tab S5e, so the frame goes through a private cache file - // instead: written from C#, read by FileChannel.read into the reused direct buffer. Only one request is in - // flight, so neither side overwrites the other. - FileStream _frameFile; - string _framePath; - IntPtr _channel, _channelRead; // global ref to the FileChannel of the file above, and its read(ByteBuffer, long) - byte[] _pixels; + long _requestTicks, _latencySum, _addSum, _timedFrames; + + // The readback callback (main thread) copies the frame into a persistent native buffer, masks it in place, and + // hands it to the push thread, which wraps the same memory as a Java direct ByteBuffer for addFrame (no Java + // array, no file). The SDK copies before addFrame returns. Only one request is in flight, so neither side + // touches the buffer while the other does. + // Allocated once per size. + NativeArray _native; + IntPtr _nativePtr; + int _nativeLength; readonly object _nativeLock = new object(); - IntPtr _directBuffer; // global ref to a reused direct java.nio.ByteBuffer - IntPtr _bufferClear, _bufferRewind; // method ids on java.nio.Buffer IntPtr _consumer; // global ref to the VideoFrameConsumer IntPtr _addFrameMethod; IntPtr _topDown, _bottomUp; // global refs to the RowOrder constants @@ -177,7 +197,6 @@ BugseeFrameCapturerStats ReadStats() long _clockOffsetMs; // fallback when TimeUtils is not found: wall clock minus SystemClock.elapsedRealtime IntPtr _elapsedClass, _elapsedMethod; readonly jvalue[] _args = new jvalue[7]; - readonly jvalue[] _readArgs = new jvalue[2]; readonly jvalue[] _noArgs = new jvalue[0]; bool _wasLaunched; bool _warnedMode; @@ -208,11 +227,8 @@ bool Begin(BugseeFrameCapturerOptions options) IntPtr cls = FindClassOrClear(ConsumerClass); _addFrameMethod = cls == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.GetMethodID(cls, "addFrame", AddFrameSignature); AndroidJNI.DeleteLocalRef(cls); - IntPtr bufferCls = AndroidJNI.FindClass("java/nio/Buffer"); - _bufferClear = AndroidJNI.GetMethodID(bufferCls, "clear", "()Ljava/nio/Buffer;"); - _bufferRewind = AndroidJNI.GetMethodID(bufferCls, "rewind", "()Ljava/nio/Buffer;"); - AndroidJNI.DeleteLocalRef(bufferCls); - if (_bufferClear == IntPtr.Zero || _bufferRewind == IntPtr.Zero + const bool bufferMethodsMissing = false; + if (bufferMethodsMissing || _addFrameMethod == IntPtr.Zero || _topDown == IntPtr.Zero || _bottomUp == IntPtr.Zero) { // On an SDK older than 7.4.1 the lookups above leave a pending NoSuchMethodError/ClassNotFoundException. @@ -222,7 +238,6 @@ bool Begin(BugseeFrameCapturerOptions options) return false; } InitClock(); - InitFrameFile(); } catch (Exception ex) { @@ -286,30 +301,6 @@ long NowMs() => _timeMethod != IntPtr.Zero ? AndroidJNI.CallStaticLongMethod(_timeClass, _timeMethod, _noArgs) : _clockOffsetMs + AndroidJNI.CallStaticLongMethod(_elapsedClass, _elapsedMethod, _noArgs); - // The private cache file and the Java FileChannel that reads it into the direct buffer. - void InitFrameFile() - { - _framePath = Path.Combine(Application.temporaryCachePath, "bugsee_frame.raw"); - _frameFile = new FileStream(_framePath, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite, 1); - IntPtr rafClass = AndroidJNI.FindClass("java/io/RandomAccessFile"); - IntPtr ctor = AndroidJNI.GetMethodID(rafClass, "", "(Ljava/lang/String;Ljava/lang/String;)V"); - var ctorArgs = new jvalue[2]; - ctorArgs[0].l = AndroidJNI.NewStringUTF(_framePath); - ctorArgs[1].l = AndroidJNI.NewStringUTF("r"); - IntPtr raf = AndroidJNI.NewObject(rafClass, ctor, ctorArgs); - AndroidJNI.DeleteLocalRef(ctorArgs[0].l); - AndroidJNI.DeleteLocalRef(ctorArgs[1].l); - AndroidJNI.DeleteLocalRef(rafClass); - if (raf == IntPtr.Zero) throw new IOException("RandomAccessFile open failed"); - IntPtr getChannel = AndroidJNI.GetMethodID(AndroidJNI.GetObjectClass(raf), "getChannel", "()Ljava/nio/channels/FileChannel;"); - IntPtr channel = AndroidJNI.CallObjectMethod(raf, getChannel, _noArgs); - AndroidJNI.DeleteLocalRef(raf); - _channel = AndroidJNI.NewGlobalRef(channel); - _channelRead = AndroidJNI.GetMethodID(AndroidJNI.GetObjectClass(channel), "read", "(Ljava/nio/ByteBuffer;J)I"); - AndroidJNI.DeleteLocalRef(channel); - if (_channel == IntPtr.Zero || _channelRead == IntPtr.Zero || ClearPendingException()) - throw new IOException("FileChannel setup failed"); - } void Stop() { @@ -331,7 +322,7 @@ void Update() Debug.Log("[BugseeFrameCapturer] requested=" + s.Requested + " pushed=" + s.Pushed + " failed=" + s.Failed + " droppedInFlight=" + s.DroppedInFlight + " droppedBlackoutOrStopped=" + s.DroppedBlackoutOrStopped + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder + (_rowOrderVerified ? "" : " (unverified)") + " api=" + SystemInfo.graphicsDeviceType - + " avgMs[readbackLatency/copy/addFrame]=" + AvgMs(_latencySum) + "/" + AvgMs(_copySum) + "/" + AvgMs(_addSum)); + + " avgMs[readbackLatency/addFrame]=" + AvgMs(Interlocked.Read(ref _latencySum)) + "/" + AvgMs(Interlocked.Read(ref _addSum))); } if (_policy.IsDisabled) _enabled = false; return; @@ -452,11 +443,21 @@ void RefreshConsumer() Debug.LogWarning("[BugseeFrameCapturer] the SDK is not launched with VideoMode.DirectBuffers; pushed frames are ignored."); } + bool HasPixelBuffer => + _nativePtr != IntPtr.Zero; + + // The frame buffer as a span (managed array, or the persistent native memory), so masking and probing do not + // care which one is in use. + Span PixelSpan() + { + unsafe { return new Span((void*)_nativePtr, _nativeLength); } + } + bool EnsureBuffers() { FramePushPolicy.ComputeCaptureSize(Screen.width, Screen.height, _options.MaxDimension, out int w, out int h); bool fullStale = _fullRt == null || _fullRt.width != Screen.width || _fullRt.height != Screen.height; - if (!fullStale && w == _captureW && h == _captureH && _smallRt != null && _pixels != null) return true; + if (!fullStale && w == _captureW && h == _captureH && _smallRt != null && HasPixelBuffer) return true; // Nothing is in flight here (this request is the only one), so the push thread is idle. ReleaseRenderTextures(); @@ -468,41 +469,28 @@ bool EnsureBuffers() _captureH = h; int bytes = (int)FramePushPolicy.FrameBytes(w, h); - _pixels = new byte[bytes]; - ReleaseDirectBuffer(); - IntPtr cls = AndroidJNI.FindClass("java/nio/ByteBuffer"); - IntPtr alloc = AndroidJNI.GetStaticMethodID(cls, "allocateDirect", "(I)Ljava/nio/ByteBuffer;"); - var arg = new jvalue[1]; - arg[0].i = bytes; - IntPtr local = AndroidJNI.CallStaticObjectMethod(cls, alloc, arg); - AndroidJNI.DeleteLocalRef(cls); - if (local == IntPtr.Zero) - { - AndroidJNI.ExceptionClear(); - _pixels = null; // so the next request retries the whole setup - return false; - } - lock (_nativeLock) - { - _directBuffer = AndroidJNI.NewGlobalRef(local); - } - AndroidJNI.DeleteLocalRef(local); + ReleaseNativeBuffer(); + _native = new NativeArray(bytes, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + unsafe { _nativePtr = (IntPtr)Unity.Collections.LowLevel.Unsafe.NativeArrayUnsafeUtility.GetUnsafePtr(_native); } + _nativeLength = bytes; return true; } void OnReadback(AsyncGPUReadbackRequest request) { - if (!_enabled || request.hasError || _pixels == null) + if (!_enabled || request.hasError || !HasPixelBuffer) { _policy.Complete(false); return; } - _latencySum += System.Diagnostics.Stopwatch.GetTimestamp() - _requestTicks; + Interlocked.Add(ref _latencySum, System.Diagnostics.Stopwatch.GetTimestamp() - _requestTicks); NativeArray data = request.GetData(); - if (data.Length < _pixels.Length) { _policy.Complete(false); return; } - NativeArray.Copy(data, _pixels, _pixels.Length); + Span pixels = PixelSpan(); + if (data.Length < pixels.Length) { _policy.Complete(false); return; } + // Not in flight (one request at a time), so the persistent buffer is ours: one native copy. + NativeArray.Copy(data, 0, _native, 0, pixels.Length); - if (_options.LogOrientationProbe && _probeFrames < ProbeFrames) LogProbe(); + if (_options.LogOrientationProbe && _probeFrames < ProbeFrames) LogProbe(pixels); // The SDK does not mask pushed frames: blank the registered secure rectangles first. If the row order is // unverified the mask could land on mirrored rows and leak the real area, so blank the whole frame instead. @@ -513,11 +501,11 @@ void OnReadback(AsyncGPUReadbackRequest request) if (count > 0 || _requestRectCount > 0) { if (_rowOrderVerified) - FrameRedaction.BlankRequestAndLive(_pixels, _captureW, _captureH, _rowOrder, + FrameRedaction.BlankRequestAndLive(pixels, _captureW, _captureH, _rowOrder, _requestRects, _requestRectCount, _requestNativeW, _requestNativeH, _rects, count, _nativeW, _nativeH); else - FrameRedaction.BlankAll(_pixels, _captureW, _captureH); + FrameRedaction.BlankAll(pixels, _captureW, _captureH); } _pushW = _captureW; @@ -528,12 +516,12 @@ void OnReadback(AsyncGPUReadbackRequest request) string AvgMs(long ticks) { - long n = _timedFrames; + long n = Interlocked.Read(ref _timedFrames); return n > 0 ? (ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / n).ToString("F1") : "-"; } // Colour of the centre pixel of the first and last buffer rows, to tell which row is the screen's top. - void LogProbe() + void LogProbe(Span pixels) { _probeFrames++; int x = _captureW / 2; @@ -541,8 +529,8 @@ void LogProbe() int a = (0 * _captureW + x) * 4; int b = (last * _captureW + x) * 4; Debug.Log("[BugseeFrameCapturer] probe api=" + SystemInfo.graphicsDeviceType + " uvStartsAtTop=" + SystemInfo.graphicsUVStartsAtTop - + " firstRow rgba=" + _pixels[a] + "," + _pixels[a + 1] + "," + _pixels[a + 2] + "," + _pixels[a + 3] - + " lastRow rgba=" + _pixels[b] + "," + _pixels[b + 1] + "," + _pixels[b + 2] + "," + _pixels[b + 3]); + + " firstRow rgba=" + pixels[a] + "," + pixels[a + 1] + "," + pixels[a + 2] + "," + pixels[a + 3] + + " lastRow rgba=" + pixels[b] + "," + pixels[b + 1] + "," + pixels[b + 2] + "," + pixels[b + 3]); } // Push thread: one frame per signal. addFrame converts synchronously on this thread, so the direct buffer can @@ -569,30 +557,27 @@ bool PushOne() { try { - long t0 = System.Diagnostics.Stopwatch.GetTimestamp(); int length = _pushW * _pushH * FramePushPolicy.PixelStride; - _frameFile.Seek(0, SeekOrigin.Begin); - _frameFile.Write(_pixels, 0, length); - _frameFile.Flush(); - AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferClear, _noArgs)); - _readArgs[0].l = _directBuffer; - _readArgs[1].j = 0; - int read = AndroidJNI.CallIntMethod(_channel, _channelRead, _readArgs); - if (ClearPendingException() || read != length) return false; - AndroidJNI.DeleteLocalRef(AndroidJNI.CallObjectMethod(_directBuffer, _bufferRewind, _noArgs)); + // The SDK reads width * height * stride bytes (stride 4) from the buffer base and needs capacity >= that; + // the native buffer was sized exactly so. It copies before addFrame returns, and the buffer is not + // reused until Complete() below lets the next request start. + if (length > _nativeLength) return false; + IntPtr buffer; + unsafe { buffer = AndroidJNI.NewDirectByteBuffer((byte*)_nativePtr, length); } + if (buffer == IntPtr.Zero) { ClearPendingException(); return false; } long t1 = System.Diagnostics.Stopwatch.GetTimestamp(); _args[0].j = _requestTimestampMs; _args[1].i = 0; // display id - _args[2].l = _directBuffer; + _args[2].l = buffer; _args[3].i = _pushW; _args[4].i = _pushH; _args[5].i = FramePushPolicy.PixelStride; _args[6].l = _pushOrder == VideoFrameRowOrder.BottomUp ? _bottomUp : _topDown; AndroidJNI.CallVoidMethod(_consumer, _addFrameMethod, _args); long t2 = System.Diagnostics.Stopwatch.GetTimestamp(); - _copySum += t1 - t0; - _addSum += t2 - t1; - _timedFrames++; + AndroidJNI.DeleteLocalRef(buffer); + Interlocked.Add(ref _addSum, t2 - t1); + Interlocked.Increment(ref _timedFrames); return !ClearPendingException(); } catch (Exception) @@ -617,32 +602,25 @@ void ReleaseRenderTextures() if (_smallRt != null) { _smallRt.Release(); Destroy(_smallRt); _smallRt = null; } } - void ReleaseDirectBuffer() + void ReleaseNativeBuffer() { - lock (_nativeLock) - { - if (_directBuffer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_directBuffer); _directBuffer = IntPtr.Zero; } - } + _nativePtr = IntPtr.Zero; + _nativeLength = 0; + if (_native.IsCreated) _native.Dispose(); } + void ReleaseNative() { - ReleaseDirectBuffer(); + ReleaseNativeBuffer(); lock (_nativeLock) { - IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass, _channel }; + IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass }; for (int i = 0; i < refs.Length; i++) if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); - _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = _channel = IntPtr.Zero; + _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = IntPtr.Zero; _timeMethod = _elapsedMethod = IntPtr.Zero; } - if (_frameFile != null) - { - _frameFile.Dispose(); - _frameFile = null; - try { File.Delete(_framePath); } catch (Exception) { } - } - _pixels = null; } void OnDestroy() diff --git a/Runtime/Internal/UnityCaptureProbe.cs b/Runtime/Internal/UnityCaptureProbe.cs new file mode 100644 index 0000000..8144d71 --- /dev/null +++ b/Runtime/Internal/UnityCaptureProbe.cs @@ -0,0 +1,26 @@ +using Bugsee.WrapperPolicy; +using UnityEngine; + +namespace Bugsee.Internal +{ + /// Reads what UnityCapturePolicy needs from the running player. + static class UnityCaptureProbe + { + public static UnityCaptureEnvironment Current() + { + return new UnityCaptureEnvironment + { + IsAndroidPlayer = Application.platform == RuntimePlatform.Android, + UnityVersion = Application.unityVersion, +#if BUGSEE_HAS_SCREENCAPTURE + ScreenCaptureModule = true, +#else + ScreenCaptureModule = false, +#endif + AsyncReadbackSupported = SystemInfo.supportsAsyncGPUReadback, + GraphicsApi = SystemInfo.graphicsDeviceType.ToString(), + RowOrderDeclared = global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions.RowOrder.HasValue + }; + } + } +} diff --git a/Runtime/Internal/UnityCaptureProbe.cs.meta b/Runtime/Internal/UnityCaptureProbe.cs.meta new file mode 100644 index 0000000..e7a1f16 --- /dev/null +++ b/Runtime/Internal/UnityCaptureProbe.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a809c282e27491590e7f40e04ce0c7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs index 4829343..99c4bf3 100644 --- a/Runtime/WrapperPolicy/FrameRedaction.cs +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -1,3 +1,4 @@ +using System; using Bugsee.Contracts.Capture; namespace Bugsee.WrapperPolicy @@ -17,12 +18,12 @@ public static class FrameRedaction /// number of rectangles that touched the frame. No allocations. /// public static int BlankRects( - byte[] rgba, int width, int height, VideoFrameRowOrder order, + Span rgba, int width, int height, VideoFrameRowOrder order, int[] rects, int count, int sourceWidth, int sourceHeight) { - if (rgba == null || rects == null || count <= 0 || rects.Length < count * 4) return 0; + if (rects == null || count <= 0 || rects.Length < count * 4) return 0; if (width <= 0 || height <= 0 || sourceWidth <= 0 || sourceHeight <= 0) return 0; - if (rgba.LongLength < (long)width * height * FramePushPolicy.PixelStride) return 0; + if (rgba.Length < (long)width * height * FramePushPolicy.PixelStride) return 0; int applied = 0; for (int i = 0; i < count; i++) @@ -50,7 +51,7 @@ public static int BlankRects( /// scaled with the native window size of its own moment: after a rotation or resize the live rectangles are in the new pixel space. /// public static int BlankRequestAndLive( - byte[] rgba, int width, int height, VideoFrameRowOrder order, + Span rgba, int width, int height, VideoFrameRowOrder order, int[] requestRects, int requestCount, int requestSourceWidth, int requestSourceHeight, int[] liveRects, int liveCount, int liveSourceWidth, int liveSourceHeight) { @@ -59,15 +60,15 @@ public static int BlankRequestAndLive( } /// Fills the whole frame with opaque black: the fail-closed choice when the row order is unverified and a secure rectangle is registered. - public static void BlankAll(byte[] rgba, int width, int height) + public static void BlankAll(Span rgba, int width, int height) { - if (rgba == null || width <= 0 || height <= 0) return; + if (width <= 0 || height <= 0) return; long pixels = (long)width * height; - if (rgba.LongLength < pixels * FramePushPolicy.PixelStride) return; + if (rgba.Length < pixels * FramePushPolicy.PixelStride) return; FillBlack(rgba, 0, (int)pixels); } - static void FillBlack(byte[] rgba, int start, int pixels) + static void FillBlack(Span rgba, int start, int pixels) { int p = start; for (int x = 0; x < pixels; x++) diff --git a/Runtime/WrapperPolicy/UnityCapturePolicy.cs b/Runtime/WrapperPolicy/UnityCapturePolicy.cs new file mode 100644 index 0000000..3864b0c --- /dev/null +++ b/Runtime/WrapperPolicy/UnityCapturePolicy.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Options; + +namespace Bugsee.WrapperPolicy +{ + /// What the wrapper knows about this Unity player when a launch asks for VideoMode.DirectBuffers. + public sealed class UnityCaptureEnvironment + { + public bool IsAndroidPlayer; + public string UnityVersion; + public bool ScreenCaptureModule; + public bool AsyncReadbackSupported; + public string GraphicsApi; + /// The host set BugseeFrameCapturerOptions.RowOrder, which declares the row order of an unmeasured API verified. + public bool RowOrderDeclared; + } + + public sealed class VideoModeDecision + { + public VideoMode Effective; + /// True when the Unity-side frame pusher must run (DirectBuffers stays and has a frame source). + public bool UseUnityCapture; + /// Why DirectBuffers was replaced by the default capture; null when it was not. + public string FallbackReason; + } + + /// + /// DirectBuffers means "the host pushes the frames". In Unity the only frame source is the Unity-side capturer, so + /// when it cannot run the wrapper launches the native SDK in its default capture mode instead, never in a + /// DirectBuffers session without frames (which records no real video). Pure logic, no Unity types. + /// + public static class UnityCapturePolicy + { + /// First Unity with AndroidJNI.NewDirectByteBuffer: 2022.2.0b1 (all of 2022.3 LTS and 6000.x have it, 2021.3 and 2022.1 do not). + public const string MinimumUnityVersion = "2022.2.0b1"; + public const int MinimumMajor = 2022; + public const int MinimumMinor = 2; + + /// The mode the wrapper falls back to: the SDK's default capture engine. + public const VideoMode FallbackMode = VideoMode.V2; + + public static bool IsSupportedUnityVersion(string unityVersion) + { + return FramePushPolicy.TryParseUnityVersion(unityVersion, out int major, out int minor, out _) + && (major > MinimumMajor || (major == MinimumMajor && minor >= MinimumMinor)); + } + + public static VideoModeDecision Decide(VideoMode requested, UnityCaptureEnvironment env) + { + if (requested != VideoMode.DirectBuffers || env == null || !env.IsAndroidPlayer) + return new VideoModeDecision { Effective = requested }; + + string reason = null; + if (!IsSupportedUnityVersion(env.UnityVersion)) + reason = "Unity " + env.UnityVersion + " has no AndroidJNI.NewDirectByteBuffer (needs " + MinimumUnityVersion + " or newer)"; + else if (!env.ScreenCaptureModule) + reason = "the Screen Capture module (com.unity.modules.screencapture) is not in the project"; + else if (!env.AsyncReadbackSupported) + reason = "this device does not support AsyncGPUReadback"; + else if (!FramePushPolicy.IsReadbackUsable(true, env.GraphicsApi == "Vulkan", env.UnityVersion)) + reason = "AsyncGPUReadback is not safe on Vulkan with this Unity version"; + else if (!env.RowOrderDeclared && !FramePushPolicy.TryMeasuredRowOrder(env.GraphicsApi, out _)) + reason = "the readback row order of " + (env.GraphicsApi ?? "this graphics API") + " is unverified (set BugseeFrameCapturer.DefaultOptions.RowOrder to declare it)"; + + return reason == null + ? new VideoModeDecision { Effective = VideoMode.DirectBuffers, UseUnityCapture = true } + : new VideoModeDecision { Effective = FallbackMode, FallbackReason = reason }; + } + + public static VideoMode RequestedMode(IDictionary options) + { + if (options == null || !options.TryGetValue(Options.CaptureVideoMode, out object value) || value == null) + return VideoMode.V2; + if (value is VideoMode mode) return mode; + try + { + return VideoModeExtensions.FromIntValue(Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture)); + } + catch (Exception) + { + return VideoMode.V2; + } + } + + /// + /// The options to hand to the native SDK: the same dictionary when nothing changes, else a copy with the video + /// mode replaced by . Options that do not set a video mode never change. + /// + public static Dictionary Apply( + Dictionary options, UnityCaptureEnvironment env, out VideoModeDecision decision) + { + if (options == null || !options.ContainsKey(Options.CaptureVideoMode)) + { + decision = new VideoModeDecision { Effective = VideoMode.V2 }; + return options; + } + decision = Decide(RequestedMode(options), env); + return decision.FallbackReason == null ? options : WithFallback(options); + } + + /// A copy of the options with the default capture mode, for when the frame source could not start after the launch. + public static Dictionary WithFallback(Dictionary options) + { + var copy = options == null ? new Dictionary() : new Dictionary(options); + copy[Options.CaptureVideoMode] = (int)FallbackMode; + return copy; + } + } +} diff --git a/Runtime/WrapperPolicy/UnityCapturePolicy.cs.meta b/Runtime/WrapperPolicy/UnityCapturePolicy.cs.meta new file mode 100644 index 0000000..a75743f --- /dev/null +++ b/Runtime/WrapperPolicy/UnityCapturePolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2e6a41b3e9ec4fb7bc8507139f1d8ef3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index b72c2a7..2e6d762 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -106,10 +106,17 @@ public void LaunchSdk() try { #if UNITY_ANDROID && !UNITY_EDITOR + // Asking for DirectBuffers means the Unity-side frame pusher; the wrapper starts it, or falls back to the + // default capture (with a warning) when this Unity cannot run it. Without the switch: the default capture. + var pusher = experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1"; + // "bugsee_frame_pattern" = 1 also paints a red-over-blue test pattern and logs the readback rows. + var pattern = ReadE2EValue("bugsee_frame_pattern", "BUGSEE_FRAME_PATTERN") == "1"; + global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions = + new global::Bugsee.Capture.BugseeFrameCapturerOptions { LogOrientationProbe = pattern }; var options = new AndroidLaunchOptions { CaptureVideo = true, - CaptureVideoMode = VideoMode.DirectBuffers, + CaptureVideoMode = pusher ? VideoMode.DirectBuffers : VideoMode.V2, CaptureVideoFrameRate = FrameRate.High, CaptureLogs = true, CaptureNetwork = true, @@ -125,17 +132,18 @@ public void LaunchSdk() if (!string.IsNullOrWhiteSpace(endpointOverride)) options.Endpoint = endpointOverride.Trim(); Bugsee.Launch(appToken, options); - if (experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1") + var reportAfter = ReadE2EValue("bugsee_report_after_s", "BUGSEE_REPORT_AFTER_S"); + if (!string.IsNullOrEmpty(reportAfter)) + StartCoroutine(ReportAfter(reportAfter)); + if (ReadE2EValue("bugsee_frame_stats", "BUGSEE_FRAME_STATS") == "1") + gameObject.AddComponent(); + if (pusher || pattern) { - // "bugsee_frame_pattern" = 1 also paints a red-over-blue test pattern and logs the readback rows. - var pattern = ReadE2EValue("bugsee_frame_pattern", "BUGSEE_FRAME_PATTERN") == "1"; if (pattern) gameObject.AddComponent(); - global::Bugsee.Capture.BugseeFrameCapturer.Enable( - new global::Bugsee.Capture.BugseeFrameCapturerOptions { LogOrientationProbe = pattern }); // "bugsee_secure_rect" = "l,t,r,b" as fractions of the screen (top-left origin); it is removed after // "bugsee_secure_rect_remove_s" seconds when that is set; "bugsee_secure_rect_delay_s" (default 2) is the wait before adding it. For checking the mask in the recorded video. - var rect = ReadE2EValue("bugsee_secure_rect", "BUGSEE_SECURE_RECT"); + var rect = ReadE2EValue("bugsee_secure_rect", "BUGSEE_SECURE_RECT"); // "l,t,r,b;l,t,r,b" if (!string.IsNullOrEmpty(rect)) StartCoroutine(SecureRectDemo(rect, ReadE2EValue("bugsee_secure_rect_remove_s", "BUGSEE_SECURE_RECT_REMOVE_S"), ReadE2EValue("bugsee_secure_rect_delay_s", "BUGSEE_SECURE_RECT_DELAY_S"))); @@ -372,24 +380,43 @@ void FireProcessFailure(string scenario) } } - IEnumerator SecureRectDemo(string fractions, string removeAfterSeconds, string delaySeconds) + // "bugsee_report_after_s" = "7,14": uploads a manual report after each delay, so the recorded video can be checked later. + IEnumerator ReportAfter(string secondsList) + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + float previous = 0f; + foreach (var item in secondsList.Split(',')) + { + if (!float.TryParse(item, System.Globalization.NumberStyles.Float, inv, out var at)) continue; + yield return new WaitForSeconds(Mathf.Max(0f, at - previous)); + previous = at; + Bugsee.Upload("frame-pusher-check-" + item, "manual report at t+" + item + "s"); + Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2", inv) + " report uploaded"); + } + } + + IEnumerator SecureRectDemo(string fractionsList, string removeAfterSeconds, string delaySeconds) { - float.TryParse(delaySeconds, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var delay); - yield return new WaitForSeconds(delay > 0f ? delay : 2f); - var p = fractions.Split(','); - if (p.Length != 4) yield break; var inv = System.Globalization.CultureInfo.InvariantCulture; - var rect = new RectInt( - (int)(float.Parse(p[0], inv) * Screen.width), (int)(float.Parse(p[1], inv) * Screen.height), - (int)((float.Parse(p[2], inv) - float.Parse(p[0], inv)) * Screen.width), - (int)((float.Parse(p[3], inv) - float.Parse(p[1], inv)) * Screen.height)); - Bugsee.AddSecureRectangle(rect); - Debug.Log("[BugseeSample] secure rect added " + rect); + float.TryParse(delaySeconds, System.Globalization.NumberStyles.Float, inv, out var delay); + yield return new WaitForSeconds(delay > 0f ? delay : 2f); + var added = new List(); + foreach (var fractions in fractionsList.Split(';')) + { + var p = fractions.Split(','); + if (p.Length != 4) continue; + float l = float.Parse(p[0], inv), t = float.Parse(p[1], inv), r = float.Parse(p[2], inv), b = float.Parse(p[3], inv); + var rect = new RectInt((int)(l * Screen.width), (int)(t * Screen.height), (int)((r - l) * Screen.width), (int)((b - t) * Screen.height)); + Bugsee.AddSecureRectangle(rect); + added.Add(rect); + Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2", inv) + " secure rect added " + rect + " screen=" + Screen.width + "x" + Screen.height); + } if (float.TryParse(removeAfterSeconds, System.Globalization.NumberStyles.Float, inv, out var seconds)) { yield return new WaitForSeconds(seconds); - Bugsee.RemoveSecureRectangle(rect); - Debug.Log("[BugseeSample] secure rect removed"); + foreach (var rect in added) + Bugsee.RemoveSecureRectangle(rect); + Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2", inv) + " secure rect removed"); } } @@ -526,7 +553,7 @@ public void RelaunchSdk() Bugsee.Relaunch(new AndroidLaunchOptions { CaptureVideo = true, - CaptureVideoMode = VideoMode.DirectBuffers + CaptureVideoMode = VideoMode.V2 }); #else Bugsee.Relaunch(); diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs new file mode 100644 index 0000000..db878d5 --- /dev/null +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs @@ -0,0 +1,32 @@ +using System; +using UnityEngine; + +namespace Bugsee.Sample +{ + /// Logs the Unity frame rate and frame-time percentiles every 10 s, to compare runs with the experimental frame pusher on and off. + public sealed class FrameTimeLogger : MonoBehaviour + { + const float WindowSeconds = 10f; + readonly float[] _frameMs = new float[4096]; + int _count; + float _elapsed; + + void Update() + { + float dt = Time.unscaledDeltaTime; + if (_count < _frameMs.Length) _frameMs[_count++] = dt * 1000f; + _elapsed += dt; + if (_elapsed < WindowSeconds) return; + + Array.Sort(_frameMs, 0, _count); + var inv = System.Globalization.CultureInfo.InvariantCulture; + Debug.Log("[BugseeSample] frames fps=" + (_count / _elapsed).ToString("F1", inv) + + " p50ms=" + _frameMs[_count / 2].ToString("F1", inv) + + " p95ms=" + _frameMs[(int)(_count * 0.95f)].ToString("F1", inv) + + " p99ms=" + _frameMs[Math.Min(_count - 1, (int)(_count * 0.99f))].ToString("F1", inv) + + " maxms=" + _frameMs[_count - 1].ToString("F1", inv)); + _count = 0; + _elapsed = 0f; + } + } +} diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs.meta b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs.meta new file mode 100644 index 0000000..547ed49 --- /dev/null +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/FrameTimeLogger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 56bd10be080342dcbf829b63fbbb0985 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs index bfe101c..517ae88 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/OrientationPatternOverlay.cs @@ -21,11 +21,17 @@ static Texture2D Solid(Color color) return texture; } + GUIStyle _style; + void OnGUI() { + if (_style == null) + _style = new GUIStyle(GUI.skin.label) { fontSize = Screen.height / 14, alignment = TextAnchor.MiddleCenter, normal = { textColor = Color.white } }; GUI.depth = -1000; GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height / 2f), _red); GUI.DrawTexture(new Rect(0, Screen.height / 2f, Screen.width, Screen.height / 2f), _blue); + // Time since startup in seconds, to line video frames up with the log (e.g. when a secure rect was removed). + GUI.Label(new Rect(0, Screen.height * 0.4f, Screen.width, Screen.height * 0.2f), Time.realtimeSinceStartup.ToString("F1", System.Globalization.CultureInfo.InvariantCulture), _style); } } } diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 651ed73..bf55846 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index 1a05e5d..96e3d79 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -133,6 +133,19 @@ public void Readback_gate(bool supported, bool vulkan, string version, bool expe Assert.That(FramePushPolicy.IsReadbackUsable(supported, vulkan, version), Is.EqualTo(expected)); } + [TestCase("2021.3.45f1", false)] + [TestCase("2022.1.24f1", false)] + [TestCase("2022.2.0f1", true)] + [TestCase("2022.3.62f3", true)] + [TestCase("2023.1.1f1", true)] + [TestCase("6000.0.84f1", true)] + [TestCase("garbage", false)] + [TestCase(null, false)] + public void Native_direct_byte_buffer_gate(string version, bool expected) + { + Assert.That(UnityCapturePolicy.IsSupportedUnityVersion(version), Is.EqualTo(expected)); + } + [Test] public void Measured_gles3_is_bottom_up_and_verified() { @@ -295,6 +308,32 @@ public void Request_rects_keep_the_request_time_window_size_after_a_rotation() Assert.That(Untouched(b, 2, 0) && Untouched(b, 0, 2), Is.True); } + [Test] + public void Masking_works_in_place_on_memory_that_is_not_a_byte_array() + { + // The zero-copy path masks a span over native memory; stackalloc stands in for it (no array, no copy). + System.Span native = stackalloc byte[W * H * 4]; + native.Fill(255); + int n = FrameRedaction.BlankRects(native, W, H, VideoFrameRowOrder.BottomUp, One(0, 0, 2, 1), 1, 4, 4); + Assert.That(n, Is.EqualTo(1)); + int lastRow = (H - 1) * W * 4; + Assert.That(native[lastRow], Is.EqualTo(0)); + Assert.That(native[lastRow + 3], Is.EqualTo(255)); + Assert.That(native[0], Is.EqualTo(255)); + FrameRedaction.BlankAll(native, W, H); + Assert.That(native[0] == 0 && native[native.Length - 4] == 0, Is.True); + } + + [Test] + public void A_span_shorter_than_the_frame_is_left_alone() + { + System.Span shortSpan = stackalloc byte[8]; + shortSpan.Fill(255); + Assert.That(FrameRedaction.BlankRects(shortSpan, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 4, 4), 1, 4, 4), Is.EqualTo(0)); + FrameRedaction.BlankAll(shortSpan, W, H); + Assert.That(shortSpan[0], Is.EqualTo(255)); + } + [Test] public void Blank_all_blanks_every_pixel_and_ignores_a_short_buffer() { diff --git a/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs b/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs new file mode 100644 index 0000000..21d5584 --- /dev/null +++ b/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.IO; +using Bugsee.Contracts.Options; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class UnityCapturePolicyTests + { + static UnityCaptureEnvironment Supported() => new UnityCaptureEnvironment + { + IsAndroidPlayer = true, + UnityVersion = "6000.0.84f1", + ScreenCaptureModule = true, + AsyncReadbackSupported = true, + GraphicsApi = "Vulkan" + }; + + static Dictionary Mode(object value) => + new Dictionary { { Options.CaptureVideoMode, value }, { "other", 1 } }; + + [Test] + public void Supported_environment_keeps_direct_buffers_and_starts_the_pusher() + { + var d = UnityCapturePolicy.Decide(VideoMode.DirectBuffers, Supported()); + Assert.That(d.Effective, Is.EqualTo(VideoMode.DirectBuffers)); + Assert.That(d.UseUnityCapture, Is.True); + Assert.That(d.FallbackReason, Is.Null); + } + + [TestCase("2021.3.45f1")] + [TestCase("2022.1.24f1")] + [TestCase("garbage")] + [TestCase(null)] + public void Old_or_unknown_unity_falls_back_to_the_default_capture(string version) + { + var env = Supported(); + env.UnityVersion = version; + var d = UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env); + Assert.That(d.Effective, Is.EqualTo(VideoMode.V2)); + Assert.That(d.UseUnityCapture, Is.False); + Assert.That(d.FallbackReason, Does.Contain("NewDirectByteBuffer")); + Assert.That(d.FallbackReason, Does.Contain(UnityCapturePolicy.MinimumUnityVersion)); + } + + [TestCase("2022.2.0b1")] + [TestCase("2022.3.62f3")] + [TestCase("6000.0.84f1")] + public void The_minimum_version_and_everything_above_it_is_supported(string version) + { + var env = Supported(); + env.UnityVersion = version; + env.GraphicsApi = "OpenGLES3"; + Assert.That(UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env).UseUnityCapture, Is.True); + } + + [Test] + public void Missing_screen_capture_module_falls_back() + { + var env = Supported(); + env.ScreenCaptureModule = false; + var d = UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env); + Assert.That(d.Effective, Is.EqualTo(VideoMode.V2)); + Assert.That(d.FallbackReason, Does.Contain("Screen Capture")); + } + + [Test] + public void No_async_readback_falls_back() + { + var env = Supported(); + env.AsyncReadbackSupported = false; + Assert.That(UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env).FallbackReason, Does.Contain("AsyncGPUReadback")); + } + + [TestCase("OpenGLES2")] + [TestCase("Direct3D11")] + [TestCase(null)] + public void Unmeasured_graphics_api_falls_back_unless_the_row_order_is_declared(string api) + { + var env = Supported(); + env.GraphicsApi = api; + var d = UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env); + Assert.That(d.Effective, Is.EqualTo(VideoMode.V2)); + Assert.That(d.FallbackReason, Does.Contain("row order")); + + env.RowOrderDeclared = true; + Assert.That(UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env).UseUnityCapture, Is.True); + } + + [TestCase(VideoMode.None)] + [TestCase(VideoMode.V1)] + [TestCase(VideoMode.V2)] + [TestCase(VideoMode.Fullscreen)] + public void Other_modes_are_never_touched(VideoMode requested) + { + var env = Supported(); + env.UnityVersion = "2021.3.45f1"; + var d = UnityCapturePolicy.Decide(requested, env); + Assert.That(d.Effective, Is.EqualTo(requested)); + Assert.That(d.UseUnityCapture, Is.False); + Assert.That(d.FallbackReason, Is.Null); + } + + [Test] + public void Direct_buffers_is_only_acted_on_for_android_players() + { + var env = Supported(); + env.IsAndroidPlayer = false; + env.UnityVersion = "2021.3.45f1"; + var d = UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env); + Assert.That(d.Effective, Is.EqualTo(VideoMode.DirectBuffers)); + Assert.That(d.UseUnityCapture, Is.False); + Assert.That(d.FallbackReason, Is.Null); + } + + [TestCase(21)] + [TestCase(21L)] + [TestCase("21")] + public void The_options_passed_on_have_the_default_mode_when_unity_is_too_old(object raw) + { + var env = Supported(); + env.UnityVersion = "2021.3.45f1"; + var input = Mode(raw); + var output = UnityCapturePolicy.Apply(input, env, out var decision); + Assert.That(output[Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(output["other"], Is.EqualTo(1)); + Assert.That(decision.FallbackReason, Is.Not.Null); + Assert.That(input[Options.CaptureVideoMode], Is.EqualTo(raw), "the caller's dictionary is not modified"); + } + + [Test] + public void The_typed_enum_value_is_recognised_too() + { + var env = Supported(); + env.UnityVersion = "2021.3.45f1"; + var output = UnityCapturePolicy.Apply(Mode(VideoMode.DirectBuffers), env, out _); + Assert.That(output[Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + } + + [Test] + public void Supported_environment_passes_the_same_dictionary_through() + { + var input = Mode((int)VideoMode.DirectBuffers); + var output = UnityCapturePolicy.Apply(input, Supported(), out var decision); + Assert.That(output, Is.SameAs(input)); + Assert.That(decision.UseUnityCapture, Is.True); + } + + [Test] + public void Options_without_a_video_mode_or_null_options_pass_through() + { + var env = Supported(); + env.UnityVersion = "2021.3.45f1"; + var none = new Dictionary { { "x", 1 } }; + Assert.That(UnityCapturePolicy.Apply(none, env, out var d1), Is.SameAs(none)); + Assert.That(d1.UseUnityCapture, Is.False); + Assert.That(UnityCapturePolicy.Apply(null, env, out var d2), Is.Null); + Assert.That(d2.FallbackReason, Is.Null); + } + + [Test] + public void With_fallback_sets_the_default_mode_on_a_copy() + { + var input = Mode((int)VideoMode.DirectBuffers); + var output = UnityCapturePolicy.WithFallback(input); + Assert.That(output[Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(input[Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.DirectBuffers)); + Assert.That(UnityCapturePolicy.WithFallback(null)[Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + } + + // The launch paths must run the policy before the options reach the bridge. Asserts on code, not prose. + static string Code(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!File.Exists(Path.Combine(dir, "package.json"))) dir = Directory.GetParent(dir).FullName; + var kept = new System.Text.StringBuilder(); + foreach (var line in File.ReadAllLines(Path.Combine(dir, relative))) + if (!line.TrimStart().StartsWith("//") && !line.TrimStart().StartsWith("///") && !line.TrimStart().StartsWith("*")) + kept.AppendLine(line); + return kept.ToString(); + } + + [TestCase("Bridge.Launch(appToken, options);")] + [TestCase("Bridge.Relaunch(options);")] + [TestCase("bridge.Launch(appToken, options, accepted")] + [TestCase("bridge.Relaunch(options, accepted")] + public void Every_launch_path_applies_the_policy_before_calling_the_bridge(string bridgeCall) + { + var code = Code("Runtime/Bugsee.cs"); + int call = code.IndexOf(bridgeCall, System.StringComparison.Ordinal); + Assert.That(call, Is.GreaterThan(0), bridgeCall); + int policy = code.LastIndexOf("options = ApplyCapturePolicy(options, out bool startPusher);", call, System.StringComparison.Ordinal); + Assert.That(policy, Is.GreaterThan(0), "no policy call before " + bridgeCall); + Assert.That(call - policy, Is.LessThan(900), "policy call is not in the same method as " + bridgeCall); + } + } +} diff --git a/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj b/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj index 6bb26d3..0b9f9c9 100644 --- a/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj +++ b/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj @@ -10,6 +10,7 @@ Bugsee.Runtime 9.0 disable + true false false false From 6f112eec63dd1c1da4c1f933d0472c7ba2d65efd Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:30:23 +0500 Subject: [PATCH 127/157] Run the pusher start and its fallback on the main thread; sample relaunch keeps the opt-in. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaptureStartup queues the start onto the main thread and relaunches with the default capture if it fails, including a pusher that stops itself later. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Bugsee.cs | 41 +++++-- Runtime/Capture/BugseeFrameCapturer.cs | 10 +- Runtime/WrapperPolicy/CaptureStartup.cs | 62 ++++++++++ Runtime/WrapperPolicy/CaptureStartup.cs.meta | 11 ++ .../BugseeHarness/BugseeSampleBootstrap.cs | 9 +- Tests~/WrapperPolicy/CaptureStartupTests.cs | 110 ++++++++++++++++++ 6 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 Runtime/WrapperPolicy/CaptureStartup.cs create mode 100644 Runtime/WrapperPolicy/CaptureStartup.cs.meta create mode 100644 Tests~/WrapperPolicy/CaptureStartupTests.cs diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 14edc6d..d994847 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -95,18 +95,43 @@ static Dictionary ApplyCapturePolicy(Dictionary return effective; } - // Starts the pusher for a DirectBuffers launch, and if it cannot start, relaunches with the default capture so the - // session still records video. Stops it when a launch no longer wants it. + // The options of the last launch that runs the pusher; the fallback relaunch uses them if the pusher stops itself. + static Dictionary _pusherLaunchOptions; + static bool _pusherFailureHooked; + + // After a DirectBuffers launch: start the pusher on the main thread, and if it cannot start (now, or later after + // repeated readback failures) relaunch with the default capture so the session still records video. Off the main + // thread the start is queued, and the fallback still runs afterwards. A launch that no longer wants the pusher + // stops it. static void AfterLaunch(bool startPusher, Dictionary options) { - if (!startPusher) + if (startPusher) { - Capture.BugseeFrameCapturer.Disable(); - return; + _pusherLaunchOptions = options; + if (!_pusherFailureHooked) + { + _pusherFailureHooked = true; + Capture.BugseeFrameCapturer.Failed += () => MainThreadDispatcher.Run(() => + { + if (_pusherLaunchOptions == null) return; + var failedOptions = _pusherLaunchOptions; + _pusherLaunchOptions = null; + CaptureStartup.OnPusherFailed(failedOptions, Bridge.Relaunch, message => Debug.LogWarning("[Bugsee] " + message)); + }); + } + } + else + { + _pusherLaunchOptions = null; } - if (Capture.BugseeFrameCapturer.Enable(Capture.BugseeFrameCapturer.DefaultOptions)) return; - Debug.LogWarning("[Bugsee] the Unity-side frame pusher could not start; relaunching with the SDK's default capture."); - Bridge.Relaunch(UnityCapturePolicy.WithFallback(options)); + CaptureStartup.Run( + startPusher, + options, + () => Capture.BugseeFrameCapturer.Enable(Capture.BugseeFrameCapturer.DefaultOptions), + Capture.BugseeFrameCapturer.Disable, + Bridge.Relaunch, + message => Debug.LogWarning("[Bugsee] " + message), + MainThreadDispatcher.Run); } /// diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index 1218ebf..dd4d8b5 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -68,6 +68,9 @@ public sealed class BugseeFrameCapturer : MonoBehaviour /// public static BugseeFrameCapturerOptions DefaultOptions = new BugseeFrameCapturerOptions(); + /// Raised on the main thread when a running pusher turned itself off after repeated readback failures. + internal static event Action Failed; + public static BugseeFrameCapturerStats Stats => _instance != null ? _instance.ReadStats() : default; /// @@ -324,7 +327,12 @@ void Update() + " size=" + _captureW + "x" + _captureH + " rowOrder=" + _rowOrder + (_rowOrderVerified ? "" : " (unverified)") + " api=" + SystemInfo.graphicsDeviceType + " avgMs[readbackLatency/addFrame]=" + AvgMs(Interlocked.Read(ref _latencySum)) + "/" + AvgMs(Interlocked.Read(ref _addSum))); } - if (_policy.IsDisabled) _enabled = false; + if (_policy.IsDisabled) + { + _enabled = false; + Debug.LogWarning("[BugseeFrameCapturer] turned itself off after repeated readback failures."); + Failed?.Invoke(); + } return; } if (_releaseDone || _policy.HasInFlight) return; diff --git a/Runtime/WrapperPolicy/CaptureStartup.cs b/Runtime/WrapperPolicy/CaptureStartup.cs new file mode 100644 index 0000000..7bff37d --- /dev/null +++ b/Runtime/WrapperPolicy/CaptureStartup.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + /// + /// What happens after a launch that asked for VideoMode.DirectBuffers: start the Unity-side pusher on the main thread, + /// and if it cannot start, relaunch with the default capture, so the session never keeps DirectBuffers without a + /// frame source. Written against delegates so it can be tested without Unity. + /// + public static class CaptureStartup + { + /// The policy decided the pusher must run. + /// The options the launch used (the fallback relaunch is these with the default mode). + /// Starts the pusher; must be called on the main thread; false when it could not start. + /// Stops a pusher a previous launch started. + /// Relaunches the native SDK with the given options. + /// Logs a warning. + /// Runs the action on the main thread (inline when already there, else queued). + public static void Run( + bool startPusher, + Dictionary options, + Func enable, + Action disable, + Action> relaunch, + Action warn, + Action runOnMainThread) + { + runOnMainThread(() => + { + if (!startPusher) + { + disable(); + return; + } + bool started; + try + { + started = enable(); + } + catch (Exception ex) + { + warn("the Unity-side frame pusher threw while starting: " + ex.Message); + started = false; + } + if (started) return; + warn("the Unity-side frame pusher could not start; relaunching with the SDK's default capture."); + relaunch(UnityCapturePolicy.WithFallback(options)); + }); + } + + /// The pusher stopped itself after launch (repeated readback failures): same fallback. + public static void OnPusherFailed( + Dictionary options, + Action> relaunch, + Action warn) + { + warn("the Unity-side frame pusher stopped after repeated failures; relaunching with the SDK's default capture."); + relaunch(UnityCapturePolicy.WithFallback(options)); + } + } +} diff --git a/Runtime/WrapperPolicy/CaptureStartup.cs.meta b/Runtime/WrapperPolicy/CaptureStartup.cs.meta new file mode 100644 index 0000000..1b1d408 --- /dev/null +++ b/Runtime/WrapperPolicy/CaptureStartup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df98334ef84b41f4b1cd890f3f73fc5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 2e6d762..c2c1dad 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -26,6 +26,7 @@ public sealed class BugseeSampleBootstrap : MonoBehaviour [SerializeField] bool installSampleFilters; /// Experimental Android frame pusher (off by default); an intent extra "bugsee_frame_pusher" = 1 also turns it on. [SerializeField] bool experimentalFramePusher; + bool _framePusherRequested; // the opt-in as resolved at launch; a relaunch keeps it readonly Queue _lifecycleLog = new Queue(); const int MaxLifecycleEntries = 24; @@ -108,7 +109,7 @@ public void LaunchSdk() #if UNITY_ANDROID && !UNITY_EDITOR // Asking for DirectBuffers means the Unity-side frame pusher; the wrapper starts it, or falls back to the // default capture (with a warning) when this Unity cannot run it. Without the switch: the default capture. - var pusher = experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1"; + _framePusherRequested = experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1"; // "bugsee_frame_pattern" = 1 also paints a red-over-blue test pattern and logs the readback rows. var pattern = ReadE2EValue("bugsee_frame_pattern", "BUGSEE_FRAME_PATTERN") == "1"; global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions = @@ -116,7 +117,7 @@ public void LaunchSdk() var options = new AndroidLaunchOptions { CaptureVideo = true, - CaptureVideoMode = pusher ? VideoMode.DirectBuffers : VideoMode.V2, + CaptureVideoMode = _framePusherRequested ? VideoMode.DirectBuffers : VideoMode.V2, CaptureVideoFrameRate = FrameRate.High, CaptureLogs = true, CaptureNetwork = true, @@ -137,7 +138,7 @@ public void LaunchSdk() StartCoroutine(ReportAfter(reportAfter)); if (ReadE2EValue("bugsee_frame_stats", "BUGSEE_FRAME_STATS") == "1") gameObject.AddComponent(); - if (pusher || pattern) + if (_framePusherRequested || pattern) { if (pattern) gameObject.AddComponent(); @@ -553,7 +554,7 @@ public void RelaunchSdk() Bugsee.Relaunch(new AndroidLaunchOptions { CaptureVideo = true, - CaptureVideoMode = VideoMode.V2 + CaptureVideoMode = _framePusherRequested ? VideoMode.DirectBuffers : VideoMode.V2 }); #else Bugsee.Relaunch(); diff --git a/Tests~/WrapperPolicy/CaptureStartupTests.cs b/Tests~/WrapperPolicy/CaptureStartupTests.cs new file mode 100644 index 0000000..f48d506 --- /dev/null +++ b/Tests~/WrapperPolicy/CaptureStartupTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using Bugsee.Contracts.Options; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class CaptureStartupTests + { + // A fake main thread: actions run inline when "on main", otherwise they wait in a queue until Drain(). + sealed class Fake + { + public bool OnMain = true; + public readonly Queue Queued = new Queue(); + public int Enables, Disables; + public bool EnableResult = true; + public bool EnableThrows; + public readonly List> Relaunches = new List>(); + public readonly List Warnings = new List(); + + public void RunOnMain(Action a) { if (OnMain) a(); else Queued.Enqueue(a); } + public void Drain() { while (Queued.Count > 0) Queued.Dequeue()(); } + + public void Start(bool startPusher, Dictionary options) + { + CaptureStartup.Run(startPusher, options, + () => { Enables++; if (EnableThrows) throw new InvalidOperationException("boom"); return EnableResult; }, + () => Disables++, + o => Relaunches.Add(new Dictionary(o)), + Warnings.Add, + RunOnMain); + } + } + + static Dictionary Direct() => + new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.DirectBuffers }, { "other", 1 } }; + + [Test] + public void Successful_start_on_the_main_thread_does_not_relaunch() + { + var f = new Fake(); + f.Start(true, Direct()); + Assert.That(f.Enables, Is.EqualTo(1)); + Assert.That(f.Relaunches, Is.Empty); + Assert.That(f.Warnings, Is.Empty); + } + + [Test] + public void Off_the_main_thread_nothing_runs_until_the_main_thread_takes_it() + { + var f = new Fake { OnMain = false }; + f.Start(true, Direct()); + Assert.That(f.Enables, Is.EqualTo(0)); + f.Drain(); + Assert.That(f.Enables, Is.EqualTo(1)); + } + + [Test] + public void A_start_that_fails_after_being_queued_still_relaunches_with_the_default_capture() + { + var f = new Fake { OnMain = false, EnableResult = false }; + f.Start(true, Direct()); + Assert.That(f.Relaunches, Is.Empty, "not before the queued start ran"); + f.Drain(); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(f.Relaunches[0]["other"], Is.EqualTo(1)); + Assert.That(f.Warnings, Has.Count.EqualTo(1)); + } + + [Test] + public void A_start_that_throws_is_treated_as_a_failed_start() + { + var f = new Fake { EnableThrows = true }; + f.Start(true, Direct()); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(f.Warnings.Count, Is.EqualTo(2)); + } + + [Test] + public void A_launch_that_does_not_want_the_pusher_stops_it_and_never_relaunches() + { + var f = new Fake(); + f.Start(false, new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.V2 } }); + Assert.That(f.Disables, Is.EqualTo(1)); + Assert.That(f.Enables, Is.EqualTo(0)); + Assert.That(f.Relaunches, Is.Empty); + } + + [Test] + public void A_pusher_that_stops_itself_later_relaunches_with_the_default_capture() + { + var relaunches = new List>(); + var warnings = new List(); + CaptureStartup.OnPusherFailed(Direct(), o => relaunches.Add(new Dictionary(o)), warnings.Add); + Assert.That(relaunches, Has.Count.EqualTo(1)); + Assert.That(relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(warnings, Has.Count.EqualTo(1)); + } + + [Test] + public void Fallback_with_null_options_still_names_the_default_mode() + { + var f = new Fake { EnableResult = false }; + f.Start(true, null); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + } + } +} From 2c938a2e8158d6c9b9523ee98dfc1b58bea37ca6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:30:43 +0500 Subject: [PATCH 128/157] Attach only unattached pool threads, one JNI scope per span finish, and wait for Launched in APM e2e. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidJniScope no longer detaches threads it did not attach: the decision is JniAttachPolicy (pool thread, not main, not already attached, not nested), the attach result is checked and the depth only counts after a successful attach. ManagedSpan holds one scope across a whole finish cascade. Docs: the native SDK returns a no-op span (ids 0) when monitoring is off; the wrapper's inert span is for not-launched only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 2 +- Runtime/Contracts/Apm/ISpan.cs | 2 +- Runtime/Platform/Android/AndroidJniScope.cs | 37 ++++++++------ Runtime/Platform/Android/AndroidSpanHandle.cs | 2 + Runtime/Platform/IOS/IosSpanHandle.cs | 8 +++ Runtime/WrapperPolicy/ISpanHandle.cs | 7 +++ Runtime/WrapperPolicy/JniAttachPolicy.cs | 16 ++++++ Runtime/WrapperPolicy/JniAttachPolicy.cs.meta | 11 ++++ Runtime/WrapperPolicy/ManagedSpan.cs | 21 +++++++- .../Assets/Scripts/BugseeHarness/ApmE2E.cs | 36 ++++++++++++- .../BugseeHarness/BugseeSampleBootstrap.cs | 15 +++++- Tests~/WrapperPolicy/JniAttachPolicyTests.cs | 18 +++++++ Tests~/WrapperPolicy/ManagedSpanTests.cs | 51 +++++++++++++++++++ Tests~/WrapperPolicy/SpanStartTests.cs | 2 + 14 files changed, 206 insertions(+), 22 deletions(-) create mode 100644 Runtime/WrapperPolicy/JniAttachPolicy.cs create mode 100644 Runtime/WrapperPolicy/JniAttachPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/JniAttachPolicyTests.cs diff --git a/Documentation~/index.md b/Documentation~/index.md index 371ba69..7128c37 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -239,6 +239,6 @@ using (var pay = checkout.StartChild("payment", "charge card")) `Bugsee.StartTransaction(name, operation, attributes = null)` returns an `ISpan` (`SpanId`, `TraceId`, `Operation`, `IsFinished`, `SetName`, `SetDescription`, `SetAttribute`, `SetStatus`, `StartChild(operation, description = null)`, `Finish()` / `Finish(SpanStatus)`, `Dispose` = `Finish()`). Attribute values follow the `SetAttribute` rules; a rejected value is dropped with a Unity warning (spans never throw). The statuses are `Ok`, `Error`, `Timeout`, `Cancelled`, `DeadlineExceeded` and `Unknown`. -A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids, reads as finished after `Finish`) while the SDK is not launched or when the name or operation is empty. With performance monitoring off, iOS returns its shared no-op span (ids read `"0"`), which is wrapped like any other span; on Android (verified on a Galaxy Tab A 8.0, Android 11) the call returns the inert span with empty ids. No call on a span throws: invalid input and native failures log a Unity warning. If a child finishes on another thread while its parent finishes, the status that reached the child first wins. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). A span can be used from any thread, including thread-pool and `Task` threads: on Android each JNI entry attaches a thread Unity did not create for the duration of the call and detaches it again (without it the JVM aborts the process on such a thread), and on iOS the handle is an id. A parent finished on one thread while a child finishes on another is safe. +A span wraps a native span, so finish or dispose every span you start. Finishing (or disposing) a span releases the native object; finishing a parent first finishes its still-live children as `Cancelled`. A span that is dropped without finishing is not reported as finished, and on iOS its native handle is released by a finalizer. After `Finish` every call on the span does nothing. `StartTransaction` returns an inert span that accepts every call (empty ids, reads as finished after `Finish`) while the SDK is not launched or when the name or operation is empty. With performance monitoring off the native SDK returns its shared no-op span (ids read `"0"`), which is wrapped like any other span. While the SDK is not launched (including while it is still launching) the wrapper itself returns an inert span with empty ids. No call on a span throws: invalid input and native failures log a Unity warning. If a child finishes on another thread while its parent finishes, the status that reached the child first wins. Spans are only created from a parent: `startSpan` and `getActiveSpan` are not exposed, because the native SDKs track the active span per thread (on iOS it is not propagated across `dispatch_async`). A span can be used from any thread, including thread-pool and `Task` threads: on Android a managed thread-pool thread is attached for the duration of each call (one attach for a whole `Finish`) and detached again, because the JVM aborts the process on a JNI call from an unattached thread; the main thread, Java-owned threads and threads you attached yourself are never attached or detached by the wrapper, so do not use spans from a pool thread you attached yourself, and on iOS the handle is an id. A parent finished on one thread while a child finishes on another is safe. Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/sdk/unity/). diff --git a/Runtime/Contracts/Apm/ISpan.cs b/Runtime/Contracts/Apm/ISpan.cs index a22e0cf..eba328b 100644 --- a/Runtime/Contracts/Apm/ISpan.cs +++ b/Runtime/Contracts/Apm/ISpan.cs @@ -12,7 +12,7 @@ namespace Bugsee.Contracts.Apm /// public interface ISpan : IDisposable { - /// The native span id ("0" for the iOS no-op span used when performance monitoring is off), or an empty string for an inert span. + /// The native span id ("0" for the native no-op span used when performance monitoring is off), or an empty string for an inert span. string SpanId { get; } /// The id of the trace the span belongs to, or an empty string for an inert span. diff --git a/Runtime/Platform/Android/AndroidJniScope.cs b/Runtime/Platform/Android/AndroidJniScope.cs index 5326f83..1736ed1 100644 --- a/Runtime/Platform/Android/AndroidJniScope.cs +++ b/Runtime/Platform/Android/AndroidJniScope.cs @@ -1,34 +1,39 @@ #if UNITY_ANDROID && !UNITY_EDITOR using System; using System.Threading; +using Bugsee.Internal; +using Bugsee.WrapperPolicy; using UnityEngine; namespace Bugsee.Platform.Android { /// - /// Makes a JNI call safe from a thread Unity did not create (a thread-pool or Task thread). Such a thread - /// is not attached to the JVM, and a JNI call from it aborts the process (CheckJNI: "use of deleted local - /// reference"; seen on a Galaxy Tab A 8.0 / Android 11). Wrap every JNI entry that spans can reach: on a - /// foreign thread the outermost scope attaches the thread and detaches it again, on the main thread (and nested - /// scopes) it does nothing, so a thread Unity attached itself is never detached. + /// Makes a JNI call safe from a managed thread-pool thread (a Task or ThreadPool thread), which the JVM + /// does not know: a JNI call from it aborts the process (CheckJNI: "use of deleted local reference"; seen on a + /// Galaxy Tab A 8.0, Android 11). Wrap every JNI entry that spans can reach. The decision is + /// : only an unattached pool thread is attached, and only a thread this scope attached is + /// detached, so the main thread, Java-owned threads (callbacks) and threads someone else attached are never touched. + /// Nested scopes cost nothing; a failed attach throws instead of letting the call abort the process. /// struct AndroidJniScope : IDisposable { - static int _mainThreadId = -1; - [ThreadStatic] static int _depth; + [ThreadStatic] static bool _weAttached; readonly bool _attached; - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - static void CaptureMainThread() => _mainThreadId = Thread.CurrentThread.ManagedThreadId; - public static AndroidJniScope Enter() { - if (_depth++ > 0 || Thread.CurrentThread.ManagedThreadId == _mainThreadId) - return new AndroidJniScope(false); - AndroidJNI.AttachCurrentThread(); - return new AndroidJniScope(true); + bool attach = JniAttachPolicy.ShouldAttach( + MainThreadDispatcher.IsMainThread, Thread.CurrentThread.IsThreadPoolThread, _weAttached, _depth); + if (attach) + { + if (AndroidJNI.AttachCurrentThread() != 0) + throw new InvalidOperationException("could not attach the calling thread to the JVM"); + _weAttached = true; + } + _depth++; + return new AndroidJniScope(attach); } AndroidJniScope(bool attached) => _attached = attached; @@ -36,7 +41,9 @@ public static AndroidJniScope Enter() public void Dispose() { _depth--; - if (_attached) AndroidJNI.DetachCurrentThread(); + if (!_attached) return; + _weAttached = false; + AndroidJNI.DetachCurrentThread(); } } } diff --git a/Runtime/Platform/Android/AndroidSpanHandle.cs b/Runtime/Platform/Android/AndroidSpanHandle.cs index d43e0a7..0a1b7c9 100644 --- a/Runtime/Platform/Android/AndroidSpanHandle.cs +++ b/Runtime/Platform/Android/AndroidSpanHandle.cs @@ -82,6 +82,8 @@ public void Release() } // The setters return the span for chaining; each call returns a new reference that must be disposed. + public IDisposable EnterScope() => AndroidJniScope.Enter(); + void Chain(string method, params object[] args) { using (AndroidJniScope.Enter()) diff --git a/Runtime/Platform/IOS/IosSpanHandle.cs b/Runtime/Platform/IOS/IosSpanHandle.cs index 5ce7c5b..b87bdab 100644 --- a/Runtime/Platform/IOS/IosSpanHandle.cs +++ b/Runtime/Platform/IOS/IosSpanHandle.cs @@ -58,6 +58,14 @@ public void Release() GC.SuppressFinalize(this); } + public IDisposable EnterScope() => NoScope.Instance; + + sealed class NoScope : IDisposable + { + public static readonly NoScope Instance = new NoScope(); + public void Dispose() { } + } + static string Read(IntPtr ptr) { if (ptr == IntPtr.Zero) return ""; diff --git a/Runtime/WrapperPolicy/ISpanHandle.cs b/Runtime/WrapperPolicy/ISpanHandle.cs index 3fcb8e0..df534b7 100644 --- a/Runtime/WrapperPolicy/ISpanHandle.cs +++ b/Runtime/WrapperPolicy/ISpanHandle.cs @@ -1,3 +1,4 @@ +using System; using Bugsee.Contracts.Apm; namespace Bugsee.WrapperPolicy @@ -23,5 +24,11 @@ public interface ISpanHandle /// Drops the wrapper's reference to the native span (a JNI global ref, a registry entry). void Release(); + + /// + /// A scope that keeps the calling thread usable for native calls until it is disposed (an Android thread-pool thread + /// is attached once for a whole finish instead of once per call). Never null. + /// + IDisposable EnterScope(); } } diff --git a/Runtime/WrapperPolicy/JniAttachPolicy.cs b/Runtime/WrapperPolicy/JniAttachPolicy.cs new file mode 100644 index 0000000..c11ff0f --- /dev/null +++ b/Runtime/WrapperPolicy/JniAttachPolicy.cs @@ -0,0 +1,16 @@ +namespace Bugsee.WrapperPolicy +{ + /// + /// When a JNI call needs this wrapper to attach (and later detach) the calling thread. A JNI call from a thread the + /// JVM does not know aborts the process, and detaching a thread that has Java frames (a callback on a Java-owned + /// thread) or that someone else attached aborts it too, so the wrapper only touches a thread it can prove is + /// unattached: a managed thread-pool thread (Unity attaches only its own threads and Java-owned ones are never in + /// the managed pool), never the main thread, never inside a scope it already holds. A thread that application code + /// attached by itself must not be a pool thread it also uses for spans. + /// + public static class JniAttachPolicy + { + public static bool ShouldAttach(bool isMainThread, bool isPoolThread, bool alreadyAttached, int depth) => + depth == 0 && !alreadyAttached && !isMainThread && isPoolThread; + } +} diff --git a/Runtime/WrapperPolicy/JniAttachPolicy.cs.meta b/Runtime/WrapperPolicy/JniAttachPolicy.cs.meta new file mode 100644 index 0000000..0c61848 --- /dev/null +++ b/Runtime/WrapperPolicy/JniAttachPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 23b6840cb6c141f7b25e22710bdea38c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/ManagedSpan.cs b/Runtime/WrapperPolicy/ManagedSpan.cs index 94994e2..eb58131 100644 --- a/Runtime/WrapperPolicy/ManagedSpan.cs +++ b/Runtime/WrapperPolicy/ManagedSpan.cs @@ -107,8 +107,25 @@ public void Finish(SpanStatus status) var handle = Detach(out var children); if (handle == null) return; - foreach (var child in children) child.FinishFromParent(); - FinishAndRelease(handle, SpanPolicy.Normalize(status)); + // One scope for the whole cascade (children and parent): one attach/detach cycle per finish. + IDisposable scope = null; + try + { + scope = handle.EnterScope(); + } + catch (Exception e) + { + Warn("Finish scope failed: " + e.Message); + } + try + { + foreach (var child in children) child.FinishFromParent(); + FinishAndRelease(handle, SpanPolicy.Normalize(status)); + } + finally + { + try { scope?.Dispose(); } catch (Exception e) { Warn("Finish scope failed: " + e.Message); } + } _parent?.Forget(this); } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs index 931cc11..83e2322 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs @@ -33,9 +33,15 @@ static T Try(string what, Func call) } } - public static void Run(string runId, bool monitoringOff, bool leak = false) + public static void Run(string runId, bool monitoringOff, bool leak = false, bool pool = false) { Log("begin run=" + runId + " monitoringOff=" + monitoringOff + " thread=" + Thread.CurrentThread.ManagedThreadId); + if (pool) + { + PoolStress(runId); + Log("end run=" + runId); + return; + } if (leak) { Loop(runId, 54000); @@ -149,5 +155,33 @@ static void Loop(string runId, int count) Bugsee.StartTransaction("apm-loop-txn", "e2e.loop").Dispose(); Log("loop done count=" + count + " ms=" + sw.ElapsedMilliseconds); } + + // Thousands of spans created and finished on thread-pool threads, plus spans dropped without finishing (their + // native references must be released by the finalizer, not leak). + static void PoolStress(string runId) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + const int Workers = 8, PerWorker = 3000; + var tasks = new Task[Workers]; + for (int w = 0; w < Workers; w++) + { + tasks[w] = Task.Run(() => + { + for (int i = 0; i < PerWorker; i++) + { + var txn = Bugsee.StartTransaction("apm-pool-stress", "e2e.stress"); + var c = txn.StartChild("stress.child"); + c.SetAttribute("i", i); + if (i % 3 == 0) continue; // dropped unfinished + c.Finish(); + txn.Finish(); + } + }); + } + Task.WaitAll(tasks); + Log("pool stress done ms=" + sw.ElapsedMilliseconds + " spans=" + (Workers * PerWorker * 2)); + for (int g = 0; g < 3; g++) { GC.Collect(); GC.WaitForPendingFinalizers(); } + Log("gc done"); + } } } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 11102d9..df07bce 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -119,7 +119,7 @@ public void LaunchSdk() DetectAndReportExitNotResponding = true, DetectAndReportExitLowMemory = true, PerformanceMonitoring = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") != "apmoff", - PerformanceSampleRate = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmleak" ? 0f : 1f, + PerformanceSampleRate = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmleak" || ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmpool" ? 0f : 1f, Debug = true }; if (!string.IsNullOrWhiteSpace(endpointOverride)) @@ -192,6 +192,7 @@ public void LaunchSdk() int _e2ePhase; float _e2eTimer; string _pendingE2EFire; + int _apmLaunchWaits; void Update() { @@ -263,6 +264,7 @@ void Update() case "apm": case "apmoff": case "apmleak": + case "apmpool": if (_e2ePhase == 0 && _e2eTimer >= 5f) { _e2ePhase = 99; @@ -325,8 +327,17 @@ void PumpPendingE2EFire(string pending) FireProcessFailure(marker); break; case 'M': + // Spans started before the SDK reaches Launched are inert wrapper spans, so wait for it. + if (!Bugsee.IsLaunched && _apmLaunchWaits++ < 120) + { + _e2ePhase = 0; + _e2eTimer = 4f; + _e2eRunId = marker; + break; + } SetStatus("E2E APM " + marker); - ApmE2E.Run(marker, _e2eScenario == "apmoff", _e2eScenario == "apmleak"); + Debug.Log("[ApmE2E] sdk status=" + Bugsee.Status + " waits=" + _apmLaunchWaits); + ApmE2E.Run(marker, _e2eScenario == "apmoff", _e2eScenario == "apmleak", _e2eScenario == "apmpool"); // The report bundle carries every transaction, which is what the backend check reads back. if (_e2eScenario == "apm") Bugsee.Upload("apm-e2e-" + marker, "APM e2e run " + marker); break; diff --git a/Tests~/WrapperPolicy/JniAttachPolicyTests.cs b/Tests~/WrapperPolicy/JniAttachPolicyTests.cs new file mode 100644 index 0000000..aef5cf3 --- /dev/null +++ b/Tests~/WrapperPolicy/JniAttachPolicyTests.cs @@ -0,0 +1,18 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class JniAttachPolicyTests + { + [TestCase(false, true, false, 0, true, TestName = "an unattached pool thread is attached")] + [TestCase(true, false, false, 0, false, TestName = "the main thread is never attached")] + [TestCase(true, true, false, 0, false, TestName = "the main thread wins over the pool flag")] + [TestCase(false, false, false, 0, false, TestName = "a non-pool thread (Unity's, a Java-owned one) is left alone")] + [TestCase(false, true, true, 0, false, TestName = "an already attached thread is not attached again")] + [TestCase(false, true, false, 1, false, TestName = "a nested scope does nothing")] + public void Attach_decision(bool isMain, bool isPool, bool alreadyAttached, int depth, bool expected) + { + Assert.That(JniAttachPolicy.ShouldAttach(isMain, isPool, alreadyAttached, depth), Is.EqualTo(expected)); + } + } +} diff --git a/Tests~/WrapperPolicy/ManagedSpanTests.cs b/Tests~/WrapperPolicy/ManagedSpanTests.cs index 431cf3e..3e5e4b4 100644 --- a/Tests~/WrapperPolicy/ManagedSpanTests.cs +++ b/Tests~/WrapperPolicy/ManagedSpanTests.cs @@ -15,6 +15,7 @@ sealed class FakeHandle : ISpanHandle public bool ReturnNullChild; public bool ThrowOnSetName; public bool ThrowOnRelease; + public bool ThrowOnScope; readonly string _name; public FakeHandle(string name, List calls) @@ -49,6 +50,23 @@ public void Finish(SpanStatus status) if (ThrowOnFinish) throw new InvalidOperationException("native finish failed"); } + public int Scopes; + public int OpenScopes; + public IDisposable EnterScope() + { + if (ThrowOnScope) throw new InvalidOperationException("attach failed"); + Scopes++; + OpenScopes++; + return new Scope(this); + } + + sealed class Scope : IDisposable + { + readonly FakeHandle _h; + public Scope(FakeHandle h) { _h = h; } + public void Dispose() => _h.OpenScopes--; + } + public void Release() { Calls.Add(_name + ":release"); @@ -285,6 +303,39 @@ public void Concurrent_finishes_release_once() Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); } + [Test] + public void One_scope_covers_the_whole_finish_cascade() + { + var (span, handle, calls, _) = Make(); + var a = span.StartChild("a"); + a.StartChild("a1"); + span.StartChild("b"); + span.Finish(); + Assert.That(handle.Scopes, Is.EqualTo(1)); + Assert.That(handle.OpenScopes, Is.EqualTo(0)); + foreach (var child in handle.Children) Assert.That(child.Scopes, Is.EqualTo(0)); + Assert.That(calls[calls.Count - 1], Is.EqualTo("root:release")); + } + + [Test] + public void The_scope_is_closed_when_a_native_finish_throws() + { + var (span, handle, _, _) = Make(); + handle.ThrowOnFinish = true; + span.Finish(); + Assert.That(handle.OpenScopes, Is.EqualTo(0)); + } + + [Test] + public void A_scope_that_cannot_be_entered_warns_and_the_finish_still_runs() + { + var (span, handle, calls, warnings) = Make(); + handle.ThrowOnScope = true; + Assert.DoesNotThrow(() => span.Finish()); + Assert.That(calls, Is.EqualTo(new[] { "root:finish Ok", "root:release" })); + Assert.That(warnings.Count, Is.EqualTo(1)); + } + [Test] public void Null_handle_is_rejected() { diff --git a/Tests~/WrapperPolicy/SpanStartTests.cs b/Tests~/WrapperPolicy/SpanStartTests.cs index b211090..098e47a 100644 --- a/Tests~/WrapperPolicy/SpanStartTests.cs +++ b/Tests~/WrapperPolicy/SpanStartTests.cs @@ -19,6 +19,8 @@ public void SetStatus(SpanStatus status) { } public ISpanHandle StartChild(string operation, string description) => null; public void Finish(SpanStatus status) { } public void Release() => Released++; + public IDisposable EnterScope() => new NoScope(); + sealed class NoScope : IDisposable { public void Dispose() { } } } [Test] From 72490373f0717bebce2783f291c8c71892b19914 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:39:49 +0500 Subject: [PATCH 129/157] Attach dedicated and Java-owned threads too, detach only pool threads. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A span call from a new Thread aborted the process (CheckJNI) on the tablet. The attach is idempotent, so every non-main managed thread is attached and only a managed thread-pool thread is detached; others stay attached until ART detaches them at thread exit. Sample gains apmthrnew, apmthrjava and apmthrui scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Platform/Android/AndroidJniScope.cs | 25 ++++---- Runtime/WrapperPolicy/JniAttachPolicy.cs | 35 +++++++--- .../Assets/Scripts/BugseeHarness/ApmE2E.cs | 64 +++++++++++++++++++ .../BugseeHarness/BugseeSampleBootstrap.cs | 8 ++- Tests~/WrapperPolicy/JniAttachPolicyTests.cs | 36 ++++++++--- 5 files changed, 138 insertions(+), 30 deletions(-) diff --git a/Runtime/Platform/Android/AndroidJniScope.cs b/Runtime/Platform/Android/AndroidJniScope.cs index 1736ed1..31b9ee9 100644 --- a/Runtime/Platform/Android/AndroidJniScope.cs +++ b/Runtime/Platform/Android/AndroidJniScope.cs @@ -8,40 +8,41 @@ namespace Bugsee.Platform.Android { /// - /// Makes a JNI call safe from a managed thread-pool thread (a Task or ThreadPool thread), which the JVM - /// does not know: a JNI call from it aborts the process (CheckJNI: "use of deleted local reference"; seen on a - /// Galaxy Tab A 8.0, Android 11). Wrap every JNI entry that spans can reach. The decision is - /// : only an unattached pool thread is attached, and only a thread this scope attached is - /// detached, so the main thread, Java-owned threads (callbacks) and threads someone else attached are never touched. - /// Nested scopes cost nothing; a failed attach throws instead of letting the call abort the process. + /// Makes a JNI call safe from a managed thread other than the main one (a Task/ThreadPool thread, a + /// dedicated new Thread, a Java-created callback thread, the UI thread): the JVM does not know a managed thread + /// until it is attached, and a JNI call from it aborts the process (CheckJNI: "use of deleted local reference"; seen + /// on a Galaxy Tab A 8.0, Android 11). Wrap every JNI entry that spans can reach. decides: + /// pool threads are attached for the scope and detached after, other threads are attached once and left attached + /// (the attach is idempotent, and nothing is detached that this scope did not attach on a pool thread). Nested scopes + /// cost nothing; a failed attach throws instead of letting the call abort the process. /// struct AndroidJniScope : IDisposable { [ThreadStatic] static int _depth; [ThreadStatic] static bool _weAttached; - readonly bool _attached; + readonly bool _detach; public static AndroidJniScope Enter() { - bool attach = JniAttachPolicy.ShouldAttach( + var mode = JniAttachPolicy.Decide( MainThreadDispatcher.IsMainThread, Thread.CurrentThread.IsThreadPoolThread, _weAttached, _depth); - if (attach) + if (mode != JniAttachMode.None) { if (AndroidJNI.AttachCurrentThread() != 0) throw new InvalidOperationException("could not attach the calling thread to the JVM"); _weAttached = true; } _depth++; - return new AndroidJniScope(attach); + return new AndroidJniScope(mode == JniAttachMode.AttachDetach); } - AndroidJniScope(bool attached) => _attached = attached; + AndroidJniScope(bool detach) => _detach = detach; public void Dispose() { _depth--; - if (!_attached) return; + if (!_detach) return; _weAttached = false; AndroidJNI.DetachCurrentThread(); } diff --git a/Runtime/WrapperPolicy/JniAttachPolicy.cs b/Runtime/WrapperPolicy/JniAttachPolicy.cs index c11ff0f..887a5dd 100644 --- a/Runtime/WrapperPolicy/JniAttachPolicy.cs +++ b/Runtime/WrapperPolicy/JniAttachPolicy.cs @@ -1,16 +1,35 @@ namespace Bugsee.WrapperPolicy { + public enum JniAttachMode + { + /// Make no attach call. + None, + + /// Attach for the scope and detach when it ends. + AttachDetach, + + /// Attach and leave the thread attached (until the thread exits). + AttachOnly + } + /// - /// When a JNI call needs this wrapper to attach (and later detach) the calling thread. A JNI call from a thread the - /// JVM does not know aborts the process, and detaching a thread that has Java frames (a callback on a Java-owned - /// thread) or that someone else attached aborts it too, so the wrapper only touches a thread it can prove is - /// unattached: a managed thread-pool thread (Unity attaches only its own threads and Java-owned ones are never in - /// the managed pool), never the main thread, never inside a scope it already holds. A thread that application code - /// attached by itself must not be a pool thread it also uses for spans. + /// What a JNI scope does for the calling thread. A JNI call from a thread the JVM does not know aborts the process + /// (CheckJNI "use of deleted local reference"), so every managed thread except the main one gets an attach: the + /// attach is idempotent, so a thread that is already attached (the Android UI thread, a Java-created thread running + /// an AndroidJavaProxy callback, a thread the application attached) is unaffected. Detaching is the dangerous + /// half (ART aborts on a detach while the thread has Java frames, and on a thread attached by someone else), so the + /// wrapper detaches only a managed thread-pool thread (the pool is managed code only, never a Java callback or a + /// thread the application attached for its own use), and leaves every other thread attached: ART detaches it when + /// the thread exits (a warning, not an abort; see the tablet evidence in the PR). /// public static class JniAttachPolicy { - public static bool ShouldAttach(bool isMainThread, bool isPoolThread, bool alreadyAttached, int depth) => - depth == 0 && !alreadyAttached && !isMainThread && isPoolThread; + /// This wrapper already attached the thread (it stays attached in ). + /// Scopes this wrapper already holds on the thread; a nested scope does nothing. + public static JniAttachMode Decide(bool isMainThread, bool isPoolThread, bool alreadyAttached, int depth) + { + if (depth > 0 || isMainThread || alreadyAttached) return JniAttachMode.None; + return isPoolThread ? JniAttachMode.AttachDetach : JniAttachMode.AttachOnly; + } } } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs index 83e2322..1369e0e 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs @@ -183,5 +183,69 @@ static void PoolStress(string runId) for (int g = 0; g < 3; g++) { GC.Collect(); GC.WaitForPendingFinalizers(); } Log("gc done"); } + + static void Work(string where) + { + var t = Thread.CurrentThread; + Log(where + " work thread=" + t.ManagedThreadId + " pool=" + t.IsThreadPoolThread + " background=" + t.IsBackground + " name=" + t.Name); + var txn = Bugsee.StartTransaction("apm-thr-" + where, "e2e.thread"); + var c = txn.StartChild("thr.child"); + c.SetAttribute("where", where); + c.Finish(); + txn.Finish(); + Log(where + " work done span=" + txn.SpanId + " finished=" + txn.IsFinished); + } + +#if UNITY_ANDROID && !UNITY_EDITOR + sealed class WorkRunnable : AndroidJavaProxy + { + readonly string _where; + public WorkRunnable(string where) : base("java.lang.Runnable") { _where = where; } + public void run() { Work(_where); } + } +#endif + + // Span use from threads the wrapper did not create: a dedicated managed Thread, a thread Java created (a + // callback), and the Android UI thread. + public static void Threads(string runId, string scenario) + { + Log("threads begin run=" + runId + " scenario=" + scenario); + switch (scenario) + { + case "apmthrnew": + { + var t = new Thread(() => Work("newthread")); + t.Start(); + t.Join(); + Log("newthread joined"); + Thread.Sleep(3000); + break; + } +#if UNITY_ANDROID && !UNITY_EDITOR + case "apmthrjava": + { + using (var thread = new AndroidJavaObject("java.lang.Thread", new WorkRunnable("javathread"))) + { + thread.Call("start"); + thread.Call("join"); + } + Log("javathread joined"); + break; + } + case "apmthrui": + { + using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var activity = player.GetStatic("currentActivity")) + { + activity.Call("runOnUiThread", new WorkRunnable("uithread")); + Thread.Sleep(2000); + } + break; + } +#endif + } + Work("main"); + Log("end run=" + runId); + } } } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index df07bce..bf16cc3 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -119,7 +119,7 @@ public void LaunchSdk() DetectAndReportExitNotResponding = true, DetectAndReportExitLowMemory = true, PerformanceMonitoring = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") != "apmoff", - PerformanceSampleRate = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmleak" || ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmpool" ? 0f : 1f, + PerformanceSampleRate = ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmleak" || ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") == "apmpool" || (ReadE2EValue("bugsee_e2e", "BUGSEE_E2E_SCENARIO") ?? "").StartsWith("apmthr") ? 0f : 1f, Debug = true }; if (!string.IsNullOrWhiteSpace(endpointOverride)) @@ -265,6 +265,9 @@ void Update() case "apmoff": case "apmleak": case "apmpool": + case "apmthrnew": + case "apmthrjava": + case "apmthrui": if (_e2ePhase == 0 && _e2eTimer >= 5f) { _e2ePhase = 99; @@ -337,7 +340,8 @@ void PumpPendingE2EFire(string pending) } SetStatus("E2E APM " + marker); Debug.Log("[ApmE2E] sdk status=" + Bugsee.Status + " waits=" + _apmLaunchWaits); - ApmE2E.Run(marker, _e2eScenario == "apmoff", _e2eScenario == "apmleak", _e2eScenario == "apmpool"); + if (_e2eScenario.StartsWith("apmthr")) ApmE2E.Threads(marker, _e2eScenario); + else ApmE2E.Run(marker, _e2eScenario == "apmoff", _e2eScenario == "apmleak", _e2eScenario == "apmpool"); // The report bundle carries every transaction, which is what the backend check reads back. if (_e2eScenario == "apm") Bugsee.Upload("apm-e2e-" + marker, "APM e2e run " + marker); break; diff --git a/Tests~/WrapperPolicy/JniAttachPolicyTests.cs b/Tests~/WrapperPolicy/JniAttachPolicyTests.cs index aef5cf3..8757956 100644 --- a/Tests~/WrapperPolicy/JniAttachPolicyTests.cs +++ b/Tests~/WrapperPolicy/JniAttachPolicyTests.cs @@ -4,15 +4,35 @@ namespace Bugsee.WrapperPolicy.Tests { public class JniAttachPolicyTests { - [TestCase(false, true, false, 0, true, TestName = "an unattached pool thread is attached")] - [TestCase(true, false, false, 0, false, TestName = "the main thread is never attached")] - [TestCase(true, true, false, 0, false, TestName = "the main thread wins over the pool flag")] - [TestCase(false, false, false, 0, false, TestName = "a non-pool thread (Unity's, a Java-owned one) is left alone")] - [TestCase(false, true, true, 0, false, TestName = "an already attached thread is not attached again")] - [TestCase(false, true, false, 1, false, TestName = "a nested scope does nothing")] - public void Attach_decision(bool isMain, bool isPool, bool alreadyAttached, int depth, bool expected) + const JniAttachMode None = JniAttachMode.None; + const JniAttachMode Detach = JniAttachMode.AttachDetach; + const JniAttachMode Only = JniAttachMode.AttachOnly; + + [TestCase(false, true, false, 0, Detach, TestName = "a pool thread is attached for the scope and detached")] + [TestCase(false, false, false, 0, Only, TestName = "a dedicated, Java-created or UI thread is attached and left attached")] + [TestCase(true, false, false, 0, None, TestName = "the main thread is never touched")] + [TestCase(true, true, false, 0, None, TestName = "the main thread wins over the pool flag")] + [TestCase(false, true, true, 0, None, TestName = "a pool thread this wrapper already attached is not attached again")] + [TestCase(false, false, true, 0, None, TestName = "a dedicated thread this wrapper already attached is not attached again")] + [TestCase(false, true, false, 1, None, TestName = "a nested scope on a pool thread does nothing")] + [TestCase(false, false, false, 2, None, TestName = "a nested scope on another thread does nothing")] + public void Attach_decision(bool isMain, bool isPool, bool weAttachedBefore, int depth, JniAttachMode expected) + { + Assert.That(JniAttachPolicy.Decide(isMain, isPool, weAttachedBefore, depth), Is.EqualTo(expected)); + } + + [Test] + public void Only_a_pool_thread_is_ever_detached() { - Assert.That(JniAttachPolicy.ShouldAttach(isMain, isPool, alreadyAttached, depth), Is.EqualTo(expected)); + foreach (bool isMain in new[] { false, true }) + foreach (bool isPool in new[] { false, true }) + foreach (bool before in new[] { false, true }) + foreach (int depth in new[] { 0, 1 }) + { + var mode = JniAttachPolicy.Decide(isMain, isPool, before, depth); + if (mode == JniAttachMode.AttachDetach) + Assert.That(isPool && !isMain && !before && depth == 0); + } } } } From e1ba5a68ac1d484afccc48fff2a6b2e1b2ededb8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:43:00 +0500 Subject: [PATCH 130/157] Pusher state follows the native launch result; fallback runs after the launch is accepted. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch/Relaunch hand their answer to CaptureStartup.OnLaunchResult, which keeps the pusher for ignored or refused requests, reads the effective video mode for accepted ones, and relaunches with the default capture if the pusher cannot start. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Bugsee.cs | 84 +++++++----- Runtime/WrapperPolicy/CaptureStartup.cs | 42 ++++-- Tests~/WrapperPolicy/CaptureStartupTests.cs | 128 +++++++++++++------ Tests~/WrapperPolicy/PublicApiSourceTests.cs | 8 +- 4 files changed, 183 insertions(+), 79 deletions(-) diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index d994847..b993715 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -66,8 +66,13 @@ public static void Launch(string appToken, Dictionary options = MainThreadDispatcher.Ensure(); options = ApplyCapturePolicy(options, out bool startPusher); - Bridge.Launch(appToken, options); - AfterLaunch(startPusher, options); + if (Bridge is ILifecycleResultBridge bridge) + bridge.Launch(appToken, options, accepted => AfterLaunchResult(accepted, startPusher, options)); + else + { + Bridge.Launch(appToken, options); + AfterLaunchResult(true, startPusher, options); + } } /// @@ -78,8 +83,13 @@ public static void Launch(string appToken, Dictionary options = public static void Relaunch(Dictionary options = null) { options = ApplyCapturePolicy(options, out bool startPusher); - Bridge.Relaunch(options); - AfterLaunch(startPusher, options); + if (Bridge is ILifecycleResultBridge bridge) + bridge.Relaunch(options, accepted => AfterLaunchResult(accepted, startPusher, options)); + else + { + Bridge.Relaunch(options); + AfterLaunchResult(true, startPusher, options); + } } // VideoMode.DirectBuffers needs a frame source, and in Unity that is the Unity-side capturer (Unity 2022.2+). When it @@ -99,34 +109,30 @@ static Dictionary ApplyCapturePolicy(Dictionary static Dictionary _pusherLaunchOptions; static bool _pusherFailureHooked; - // After a DirectBuffers launch: start the pusher on the main thread, and if it cannot start (now, or later after - // repeated readback failures) relaunch with the default capture so the session still records video. Off the main - // thread the start is queued, and the fallback still runs afterwards. A launch that no longer wants the pusher - // stops it. - static void AfterLaunch(bool startPusher, Dictionary options) + // The pusher follows what the native SDK actually did with a launch request, once its answer is known: an ignored or + // refused request (e.g. a Launch while the SDK is running) leaves it alone, an accepted one is read back from the + // effective launch options. If it cannot start (now, or later after repeated readback failures) the session is + // relaunched with the default capture. All of it runs on the main thread. + static void AfterLaunchResult(bool accepted, bool startPusher, Dictionary options) { - if (startPusher) + if (accepted && startPusher) _pusherLaunchOptions = options; + if (startPusher && !_pusherFailureHooked) { - _pusherLaunchOptions = options; - if (!_pusherFailureHooked) + _pusherFailureHooked = true; + Capture.BugseeFrameCapturer.Failed += () => MainThreadDispatcher.Run(() => { - _pusherFailureHooked = true; - Capture.BugseeFrameCapturer.Failed += () => MainThreadDispatcher.Run(() => - { - if (_pusherLaunchOptions == null) return; - var failedOptions = _pusherLaunchOptions; - _pusherLaunchOptions = null; - CaptureStartup.OnPusherFailed(failedOptions, Bridge.Relaunch, message => Debug.LogWarning("[Bugsee] " + message)); - }); - } - } - else - { - _pusherLaunchOptions = null; + if (_pusherLaunchOptions == null) return; + var failedOptions = _pusherLaunchOptions; + _pusherLaunchOptions = null; + CaptureStartup.OnPusherFailed(failedOptions, Bridge.Relaunch, message => Debug.LogWarning("[Bugsee] " + message)); + }); } - CaptureStartup.Run( + CaptureStartup.OnLaunchResult( + accepted, startPusher, options, + () => IsStartingOrLaunched(Bridge.GetStatus()), + EffectiveVideoMode, () => Capture.BugseeFrameCapturer.Enable(Capture.BugseeFrameCapturer.DefaultOptions), Capture.BugseeFrameCapturer.Disable, Bridge.Relaunch, @@ -134,6 +140,16 @@ static void AfterLaunch(bool startPusher, Dictionary options) MainThreadDispatcher.Run); } + // The video mode the native SDK is really running with, from its effective launch options; null when unreadable. + static VideoMode? EffectiveVideoMode() + { + IReadOnlyDictionary launch = Bridge.GetLaunchOptions(); + if (launch == null || !launch.TryGetValue(Options.CaptureVideoMode, out object mode) || mode == null) return null; + return int.TryParse(Convert.ToString(mode, System.Globalization.CultureInfo.InvariantCulture), out int value) + ? VideoModeExtensions.FromIntValue(value) + : (VideoMode?)null; + } + /// /// Stop Bugsee. runs once the SDK has finished stopping. A Launch/Relaunch /// requested during the stop waits for it, and the completion runs before that Launch does. If the @@ -166,14 +182,18 @@ public static Task LaunchAsync(string appToken, Dictionary var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { - bridge.Launch(appToken, options, accepted => result.TrySetResult(accepted)); + bridge.Launch(appToken, options, accepted => + { + AfterLaunchResult(accepted, startPusher, options); + result.TrySetResult(accepted); + }); } else { Bridge.Launch(appToken, options); + AfterLaunchResult(true, startPusher, options); result.TrySetResult(IsStartingOrLaunched(Bridge.GetStatus())); } - AfterLaunch(startPusher, options); return result.Task; } @@ -184,14 +204,18 @@ public static Task RelaunchAsync(Dictionary options = null var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { - bridge.Relaunch(options, accepted => result.TrySetResult(accepted)); + bridge.Relaunch(options, accepted => + { + AfterLaunchResult(accepted, startPusher, options); + result.TrySetResult(accepted); + }); } else { Bridge.Relaunch(options); + AfterLaunchResult(true, startPusher, options); result.TrySetResult(IsStartingOrLaunched(Bridge.GetStatus())); } - AfterLaunch(startPusher, options); return result.Task; } diff --git a/Runtime/WrapperPolicy/CaptureStartup.cs b/Runtime/WrapperPolicy/CaptureStartup.cs index 7bff37d..fbdf845 100644 --- a/Runtime/WrapperPolicy/CaptureStartup.cs +++ b/Runtime/WrapperPolicy/CaptureStartup.cs @@ -1,25 +1,35 @@ using System; using System.Collections.Generic; +using Bugsee.Contracts.Options; namespace Bugsee.WrapperPolicy { /// - /// What happens after a launch that asked for VideoMode.DirectBuffers: start the Unity-side pusher on the main thread, - /// and if it cannot start, relaunch with the default capture, so the session never keeps DirectBuffers without a - /// frame source. Written against delegates so it can be tested without Unity. + /// Keeps the Unity-side frame pusher in step with the native SDK. The pusher must run exactly when the native SDK + /// is in VideoMode.DirectBuffers, so its state follows what the native launch actually did, not what was asked + /// for: a Launch the SDK ignored (already running) or rejected leaves the pusher alone, and an accepted one is + /// read back from the effective launch options. If the pusher cannot start, the session is relaunched with the + /// default capture. Written against delegates so it can be tested without Unity. /// public static class CaptureStartup { - /// The policy decided the pusher must run. - /// The options the launch used (the fallback relaunch is these with the default mode). - /// Starts the pusher; must be called on the main thread; false when it could not start. - /// Stops a pusher a previous launch started. - /// Relaunches the native SDK with the given options. + /// Call with the answer of a Launch/Relaunch request, once it is known. + /// The native SDK accepted the request (it ran). + /// The capture policy decided the request wants the pusher (DirectBuffers kept). + /// The options the request used (a fallback relaunch is these with the default mode). + /// The native SDK is launching or launched. + /// The video mode the native SDK reports in its launch options, or null when unreadable. + /// Starts the pusher; main thread only; false when it could not start. + /// Stops the pusher. + /// Relaunches the native SDK with the given options (the SDK is launched when this runs). /// Logs a warning. /// Runs the action on the main thread (inline when already there, else queued). - public static void Run( - bool startPusher, + public static void OnLaunchResult( + bool accepted, + bool startRequested, Dictionary options, + Func isRunning, + Func effectiveMode, Func enable, Action disable, Action> relaunch, @@ -28,11 +38,21 @@ public static void Run( { runOnMainThread(() => { - if (!startPusher) + if (!accepted) + { + // Ignored or refused: the native session, if any, is unchanged, so the pusher is too. + if (!isRunning()) disable(); + return; + } + + VideoMode? mode = effectiveMode(); + bool wantPusher = mode.HasValue ? mode.Value == VideoMode.DirectBuffers : startRequested; + if (!wantPusher) { disable(); return; } + bool started; try { diff --git a/Tests~/WrapperPolicy/CaptureStartupTests.cs b/Tests~/WrapperPolicy/CaptureStartupTests.cs index f48d506..5e1198e 100644 --- a/Tests~/WrapperPolicy/CaptureStartupTests.cs +++ b/Tests~/WrapperPolicy/CaptureStartupTests.cs @@ -7,60 +7,116 @@ namespace Bugsee.WrapperPolicy.Tests { public class CaptureStartupTests { - // A fake main thread: actions run inline when "on main", otherwise they wait in a queue until Drain(). + // A fake native SDK and main thread. The native SDK keeps the video mode of the session that is running. sealed class Fake { public bool OnMain = true; public readonly Queue Queued = new Queue(); - public int Enables, Disables; + public bool Running; + public VideoMode? NativeMode; // what getLaunchOptions reports (null = unreadable) public bool EnableResult = true; public bool EnableThrows; + public bool PusherOn; + public int Enables, Disables; public readonly List> Relaunches = new List>(); public readonly List Warnings = new List(); public void RunOnMain(Action a) { if (OnMain) a(); else Queued.Enqueue(a); } public void Drain() { while (Queued.Count > 0) Queued.Dequeue()(); } - public void Start(bool startPusher, Dictionary options) + public void Result(bool accepted, bool startRequested, Dictionary options) { - CaptureStartup.Run(startPusher, options, - () => { Enables++; if (EnableThrows) throw new InvalidOperationException("boom"); return EnableResult; }, - () => Disables++, + CaptureStartup.OnLaunchResult(accepted, startRequested, options, + () => Running, + () => NativeMode, + () => { Enables++; if (EnableThrows) throw new InvalidOperationException("boom"); PusherOn = EnableResult; return EnableResult; }, + () => { Disables++; PusherOn = false; }, o => Relaunches.Add(new Dictionary(o)), Warnings.Add, RunOnMain); } } - static Dictionary Direct() => - new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.DirectBuffers }, { "other", 1 } }; + static Dictionary Mode(VideoMode m) => + new Dictionary { { Options.CaptureVideoMode, (int)m }, { "other", 1 } }; [Test] - public void Successful_start_on_the_main_thread_does_not_relaunch() + public void Accepted_direct_buffers_launch_starts_the_pusher_without_a_relaunch() { - var f = new Fake(); - f.Start(true, Direct()); - Assert.That(f.Enables, Is.EqualTo(1)); + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.PusherOn, Is.True); Assert.That(f.Relaunches, Is.Empty); Assert.That(f.Warnings, Is.Empty); } [Test] - public void Off_the_main_thread_nothing_runs_until_the_main_thread_takes_it() + public void Accepted_launch_with_an_unreadable_mode_follows_the_request() + { + var on = new Fake { Running = true, NativeMode = null }; + on.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(on.PusherOn, Is.True); + var off = new Fake { Running = true, NativeMode = null, PusherOn = true }; + off.Result(true, false, Mode(VideoMode.V2)); + Assert.That(off.PusherOn, Is.False); + } + + [Test] + public void A_launch_the_sdk_ignored_while_running_keeps_the_pusher_of_the_running_session() + { + // Running in DirectBuffers with the pusher on; another Launch asks for V2 and the SDK ignores it: it still + // reports DirectBuffers, so the pusher must stay. + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, PusherOn = true }; + f.Result(true, false, Mode(VideoMode.V2)); + Assert.That(f.PusherOn, Is.True); + Assert.That(f.Disables, Is.EqualTo(0)); + } + + [Test] + public void A_launch_the_sdk_ignored_while_running_does_not_start_a_pusher_for_a_default_session() + { + var f = new Fake { Running = true, NativeMode = VideoMode.V2 }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.PusherOn, Is.False); + Assert.That(f.Enables, Is.EqualTo(0)); + Assert.That(f.Relaunches, Is.Empty); + } + + [Test] + public void A_rejected_request_while_the_sdk_runs_leaves_the_pusher_alone() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, PusherOn = true }; + f.Result(false, false, Mode(VideoMode.V2)); + Assert.That(f.PusherOn, Is.True); + Assert.That(f.Disables, Is.EqualTo(0)); + Assert.That(f.Relaunches, Is.Empty); + } + + [Test] + public void A_rejected_request_with_nothing_running_stops_a_stale_pusher() + { + var f = new Fake { Running = false, PusherOn = true }; + f.Result(false, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.PusherOn, Is.False); + Assert.That(f.Relaunches, Is.Empty); + } + + [Test] + public void Async_result_runs_on_the_main_thread_only_when_it_arrives_there() { - var f = new Fake { OnMain = false }; - f.Start(true, Direct()); + var f = new Fake { OnMain = false, Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); Assert.That(f.Enables, Is.EqualTo(0)); f.Drain(); - Assert.That(f.Enables, Is.EqualTo(1)); + Assert.That(f.PusherOn, Is.True); } [Test] public void A_start_that_fails_after_being_queued_still_relaunches_with_the_default_capture() { - var f = new Fake { OnMain = false, EnableResult = false }; - f.Start(true, Direct()); - Assert.That(f.Relaunches, Is.Empty, "not before the queued start ran"); + var f = new Fake { OnMain = false, Running = true, NativeMode = VideoMode.DirectBuffers, EnableResult = false }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.Relaunches, Is.Empty, "not before the queued result ran"); f.Drain(); Assert.That(f.Relaunches, Has.Count.EqualTo(1)); Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); @@ -71,21 +127,29 @@ public void A_start_that_fails_after_being_queued_still_relaunches_with_the_defa [Test] public void A_start_that_throws_is_treated_as_a_failed_start() { - var f = new Fake { EnableThrows = true }; - f.Start(true, Direct()); + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, EnableThrows = true }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); Assert.That(f.Relaunches, Has.Count.EqualTo(1)); - Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); Assert.That(f.Warnings.Count, Is.EqualTo(2)); } [Test] - public void A_launch_that_does_not_want_the_pusher_stops_it_and_never_relaunches() + public void Accepted_default_capture_launch_stops_the_pusher() { - var f = new Fake(); - f.Start(false, new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.V2 } }); - Assert.That(f.Disables, Is.EqualTo(1)); - Assert.That(f.Enables, Is.EqualTo(0)); + var f = new Fake { Running = true, NativeMode = VideoMode.V2, PusherOn = true }; + f.Result(true, false, Mode(VideoMode.V2)); + Assert.That(f.PusherOn, Is.False); + } + + [Test] + public void The_fallback_relaunch_runs_after_the_launch_was_accepted_not_while_it_is_still_launching() + { + // The relaunch is requested from the accepted-result callback, i.e. when the SDK is launched; an enable + // failure never issues it before that result exists. + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, EnableResult = false }; Assert.That(f.Relaunches, Is.Empty); + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); } [Test] @@ -93,18 +157,10 @@ public void A_pusher_that_stops_itself_later_relaunches_with_the_default_capture { var relaunches = new List>(); var warnings = new List(); - CaptureStartup.OnPusherFailed(Direct(), o => relaunches.Add(new Dictionary(o)), warnings.Add); + CaptureStartup.OnPusherFailed(Mode(VideoMode.DirectBuffers), o => relaunches.Add(new Dictionary(o)), warnings.Add); Assert.That(relaunches, Has.Count.EqualTo(1)); Assert.That(relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); Assert.That(warnings, Has.Count.EqualTo(1)); } - - [Test] - public void Fallback_with_null_options_still_names_the_default_mode() - { - var f = new Fake { EnableResult = false }; - f.Start(true, null); - Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); - } } } diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 7bf6ab0..dc28bcd 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -149,9 +149,13 @@ public void Facade_async_lifecycle_uses_the_result_bridge_and_never_blocks() { string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); string launch = ExtractMethodBody(facade, "public static Task LaunchAsync(string appToken, Dictionary options = null)"); - Assert.That(launch, Does.Contain("bridge.Launch(appToken, options, accepted => result.TrySetResult(accepted))")); + Assert.That(launch, Does.Contain("bridge.Launch(appToken, options, accepted =>")); + Assert.That(launch, Does.Contain("AfterLaunchResult(accepted, startPusher, options);")); + Assert.That(launch, Does.Contain("result.TrySetResult(accepted);")); string relaunch = ExtractMethodBody(facade, "public static Task RelaunchAsync(Dictionary options = null)"); - Assert.That(relaunch, Does.Contain("bridge.Relaunch(options, accepted => result.TrySetResult(accepted))")); + Assert.That(relaunch, Does.Contain("bridge.Relaunch(options, accepted =>")); + Assert.That(relaunch, Does.Contain("AfterLaunchResult(accepted, startPusher, options);")); + Assert.That(relaunch, Does.Contain("result.TrySetResult(accepted);")); string stop = ExtractMethodBody(facade, "public static Task StopAsync()"); Assert.That(stop, Does.Contain("bridge.Stop(")); Assert.That(stop, Does.Contain("TimeoutException")); From 2637eca92df8cd71cb2efb514724feb9d3145640 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:45:56 +0500 Subject: [PATCH 131/157] Docs: tablet measurements and what was verified. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Documentation~/index.md b/Documentation~/index.md index 745a663..fda8297 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -256,3 +256,5 @@ What to know before using it: - Row order of the readback was measured for OpenGLES3 (Android 16 arm64 emulator: BottomUp) and Vulkan (Galaxy Tab S5e, Android 11, Adreno 615: TopDown). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (not reachable on supported versions). After 5 failed readbacks in a row it turns itself off. - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. +- Measured once, on a Galaxy Tab S5e (Android 11, Vulkan, Unity 6000.0.84f1 IL2CPP development build, the sample scene, 400x640 capture at `TargetFps` 5, about 100 s per run, pusher on versus the SDK's default capture): about 4.6 frames/s pushed, 0 failures, 2-3 frames dropped in flight; readback latency 81-90 ms; `addFrame` 18-22 ms on the push thread. Unity frame rate 38.3 versus 38.6 fps, frame time p50 33.1 ms and p95 33.1 ms in both. The default capture's `BugseeScreenCap` thread (about 6-13% of a core) is replaced by the push thread (about 3-7%); main thread about 47-57% of a core either way; total PSS 383-391 MB versus 389-400 MB. One device and one light scene, not a benchmark of heavier games. +- Decoded report videos (640x640 h264) with a red-top/blue-bottom pattern and two secure rectangles (top-left and bottom-right): red on top, blue below (no mirroring); both rectangles black for the whole time they were registered and at the right place; after removal the same areas show red and blue; no unmasked frame while registered. Not verified on a device: rotation (the tablet kept portrait with the system rotation settings), OpenGLES2, other Unity lines than 2021.3.45 (fallback) and 6000.0.84 (Unity-side capture); Unity 2022.3 was not built. From f9d838bf3b287fc8512230b723d2cce1188a0549 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:57:10 +0500 Subject: [PATCH 132/157] Re-attach dedicated threads every scope, guard the launched check, tidy the thread e2e. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JniAttachPolicy no longer caches an attach for dedicated threads (an external detach could leave one unattached); SpanStart now guards the launched check so a failed attach warns and returns an inert span; the UI-thread e2e observes its callback instead of sleeping and the Android-only thread scenarios report unsupported elsewhere. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/Bugsee.cs | 3 +- Runtime/Platform/Android/AndroidJniScope.cs | 2 +- Runtime/WrapperPolicy/JniAttachPolicy.cs | 10 ++++-- Runtime/WrapperPolicy/SpanStart.cs | 10 +++--- .../Assets/Scripts/BugseeHarness/ApmE2E.cs | 32 ++++++++++++++++--- Tests~/WrapperPolicy/JniAttachPolicyTests.cs | 8 ++--- Tests~/WrapperPolicy/SpanStartTests.cs | 26 +++++++++++++-- 7 files changed, 69 insertions(+), 22 deletions(-) diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index a742f90..55f08fc 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -292,13 +292,12 @@ public static ISpan StartTransaction(string name, string operation, IDictionary< Debug.LogWarning("[Bugsee] StartTransaction ignored: a name and an operation are required."); return new NoopSpan(); } - if (!Bridge.GetLaunched()) return new NoopSpan(); - var rejected = new List(); var clean = SpanPolicy.CleanAttributes(attributes, rejected); foreach (var reason in rejected) Debug.LogWarning("[Bugsee] StartTransaction attribute dropped: " + reason + "."); return SpanStart.Transaction( + () => Bridge.GetLaunched(), () => Bridge.StartTransaction(name, operation, clean), operation, message => Debug.LogWarning("[Bugsee] " + message)); diff --git a/Runtime/Platform/Android/AndroidJniScope.cs b/Runtime/Platform/Android/AndroidJniScope.cs index 31b9ee9..ba19972 100644 --- a/Runtime/Platform/Android/AndroidJniScope.cs +++ b/Runtime/Platform/Android/AndroidJniScope.cs @@ -31,7 +31,7 @@ public static AndroidJniScope Enter() { if (AndroidJNI.AttachCurrentThread() != 0) throw new InvalidOperationException("could not attach the calling thread to the JVM"); - _weAttached = true; + _weAttached = mode == JniAttachMode.AttachDetach; } _depth++; return new AndroidJniScope(mode == JniAttachMode.AttachDetach); diff --git a/Runtime/WrapperPolicy/JniAttachPolicy.cs b/Runtime/WrapperPolicy/JniAttachPolicy.cs index 887a5dd..eb4ecf8 100644 --- a/Runtime/WrapperPolicy/JniAttachPolicy.cs +++ b/Runtime/WrapperPolicy/JniAttachPolicy.cs @@ -24,12 +24,16 @@ public enum JniAttachMode /// public static class JniAttachPolicy { - /// This wrapper already attached the thread (it stays attached in ). + /// This wrapper holds a pool-thread attach () on the thread. /// Scopes this wrapper already holds on the thread; a nested scope does nothing. public static JniAttachMode Decide(bool isMainThread, bool isPoolThread, bool alreadyAttached, int depth) { - if (depth > 0 || isMainThread || alreadyAttached) return JniAttachMode.None; - return isPoolThread ? JniAttachMode.AttachDetach : JniAttachMode.AttachOnly; + if (depth > 0 || isMainThread) return JniAttachMode.None; + // A pool thread is attached only for a scope, so "already attached" is a nested case. A dedicated thread is + // attached again every outermost scope: the attach is idempotent, and a cached flag would go stale if the + // application detached the thread itself. + if (isPoolThread) return alreadyAttached ? JniAttachMode.None : JniAttachMode.AttachDetach; + return JniAttachMode.AttachOnly; } } } diff --git a/Runtime/WrapperPolicy/SpanStart.cs b/Runtime/WrapperPolicy/SpanStart.cs index 16d47cd..fbbc319 100644 --- a/Runtime/WrapperPolicy/SpanStart.cs +++ b/Runtime/WrapperPolicy/SpanStart.cs @@ -7,15 +7,17 @@ namespace Bugsee.WrapperPolicy public static class SpanStart { /// - /// Starts a transaction through and wraps the handle. Any failure (the native call, or - /// building the wrapper, in which case the handle is released) is reported through and - /// yields an inert span. + /// Starts a transaction through and wraps the handle, once + /// says the SDK is launched (otherwise an inert span, and is not called). Any failure (the + /// launched check, the native call, or building the wrapper, in which case the handle is released) is reported + /// through and yields an inert span. /// - public static ISpan Transaction(Func start, string operation, Action warn) + public static ISpan Transaction(Func isLaunched, Func start, string operation, Action warn) { ISpanHandle handle = null; try { + if (!isLaunched()) return new NoopSpan(); handle = start(); return handle == null ? new NoopSpan() : new ManagedSpan(handle, operation, warn); } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs index 1369e0e..2fb0a6a 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/ApmE2E.cs @@ -200,8 +200,13 @@ static void Work(string where) sealed class WorkRunnable : AndroidJavaProxy { readonly string _where; - public WorkRunnable(string where) : base("java.lang.Runnable") { _where = where; } - public void run() { Work(_where); } + readonly Action _done; + public WorkRunnable(string where, Action done = null) : base("java.lang.Runnable") { _where = where; _done = done; } + public void run() + { + Work(_where); + _done?.Invoke(); + } } #endif @@ -210,6 +215,13 @@ sealed class WorkRunnable : AndroidJavaProxy public static void Threads(string runId, string scenario) { Log("threads begin run=" + runId + " scenario=" + scenario); +#if !(UNITY_ANDROID && !UNITY_EDITOR) + if (scenario != "apmthrnew") + { + Log("UNSUPPORTED: " + scenario + " needs Android; nothing was exercised"); + return; + } +#endif switch (scenario) { case "apmthrnew": @@ -234,13 +246,23 @@ public static void Threads(string runId, string scenario) } case "apmthrui": { + // Not blocking Update: the callback reports its own completion, and a watchdog reports a timeout. + int done = 0; using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = player.GetStatic("currentActivity")) { - activity.Call("runOnUiThread", new WorkRunnable("uithread")); - Thread.Sleep(2000); + activity.Call("runOnUiThread", new WorkRunnable("uithread", () => + { + Interlocked.Exchange(ref done, 1); + Log("uithread callback finished"); + })); } - break; + Task.Delay(10000).ContinueWith(_ => + { + if (Volatile.Read(ref done) == 0) Log("TIMEOUT: the UI-thread callback did not run"); + Log("end run=" + runId); + }); + return; } #endif } diff --git a/Tests~/WrapperPolicy/JniAttachPolicyTests.cs b/Tests~/WrapperPolicy/JniAttachPolicyTests.cs index 8757956..716579a 100644 --- a/Tests~/WrapperPolicy/JniAttachPolicyTests.cs +++ b/Tests~/WrapperPolicy/JniAttachPolicyTests.cs @@ -12,13 +12,13 @@ public class JniAttachPolicyTests [TestCase(false, false, false, 0, Only, TestName = "a dedicated, Java-created or UI thread is attached and left attached")] [TestCase(true, false, false, 0, None, TestName = "the main thread is never touched")] [TestCase(true, true, false, 0, None, TestName = "the main thread wins over the pool flag")] - [TestCase(false, true, true, 0, None, TestName = "a pool thread this wrapper already attached is not attached again")] - [TestCase(false, false, true, 0, None, TestName = "a dedicated thread this wrapper already attached is not attached again")] + [TestCase(false, true, true, 0, None, TestName = "a pool thread whose attach this wrapper holds is not attached again")] + [TestCase(false, false, true, 0, Only, TestName = "a dedicated thread is attached again (idempotent), so an external detach cannot leave it unattached")] [TestCase(false, true, false, 1, None, TestName = "a nested scope on a pool thread does nothing")] [TestCase(false, false, false, 2, None, TestName = "a nested scope on another thread does nothing")] - public void Attach_decision(bool isMain, bool isPool, bool weAttachedBefore, int depth, JniAttachMode expected) + public void Attach_decision(bool isMain, bool isPool, bool holdsPoolAttach, int depth, JniAttachMode expected) { - Assert.That(JniAttachPolicy.Decide(isMain, isPool, weAttachedBefore, depth), Is.EqualTo(expected)); + Assert.That(JniAttachPolicy.Decide(isMain, isPool, holdsPoolAttach, depth), Is.EqualTo(expected)); } [Test] diff --git a/Tests~/WrapperPolicy/SpanStartTests.cs b/Tests~/WrapperPolicy/SpanStartTests.cs index 098e47a..69aabcb 100644 --- a/Tests~/WrapperPolicy/SpanStartTests.cs +++ b/Tests~/WrapperPolicy/SpanStartTests.cs @@ -28,7 +28,7 @@ public void A_throwing_native_start_warns_and_returns_an_inert_span() { var warnings = new List(); ISpan span = null; - Assert.DoesNotThrow(() => span = SpanStart.Transaction(() => throw new InvalidOperationException("jni"), "op", warnings.Add)); + Assert.DoesNotThrow(() => span = SpanStart.Transaction(() => true, () => throw new InvalidOperationException("jni"), "op", warnings.Add)); Assert.That(span, Is.InstanceOf()); Assert.That(warnings.Count, Is.EqualTo(1)); Assert.That(warnings[0], Does.Contain("jni")); @@ -38,15 +38,35 @@ public void A_throwing_native_start_warns_and_returns_an_inert_span() public void A_null_handle_is_inert_without_a_warning() { var warnings = new List(); - Assert.That(SpanStart.Transaction(() => null, "op", warnings.Add), Is.InstanceOf()); + Assert.That(SpanStart.Transaction(() => true, () => null, "op", warnings.Add), Is.InstanceOf()); Assert.That(warnings, Is.Empty); } + [Test] + public void Not_launched_is_inert_and_never_starts_natively() + { + bool started = false; + var span = SpanStart.Transaction(() => false, () => { started = true; return new Handle(); }, "op", null); + Assert.That(span, Is.InstanceOf()); + Assert.That(started, Is.False); + } + + [Test] + public void A_throwing_launched_check_warns_and_returns_an_inert_span() + { + var warnings = new List(); + ISpan span = null; + Assert.DoesNotThrow(() => span = SpanStart.Transaction(() => throw new InvalidOperationException("attach"), () => new Handle(), "op", warnings.Add)); + Assert.That(span, Is.InstanceOf()); + Assert.That(warnings.Count, Is.EqualTo(1)); + Assert.That(warnings[0], Does.Contain("attach")); + } + [Test] public void A_handle_is_wrapped() { var handle = new Handle(); - var span = SpanStart.Transaction(() => handle, "op", null); + var span = SpanStart.Transaction(() => true, () => handle, "op", null); Assert.That(span, Is.InstanceOf()); Assert.That(span.SpanId, Is.EqualTo("s")); } From 9ae3c9e5cfaa6826ffd590d732ba5ac9d1ba3b00 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:03:12 +0500 Subject: [PATCH 133/157] Review fixes: main-thread probe cache, fail-closed masking with the view position, launch-state tracking, JNI cleanup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SystemInfo is read once on the main thread and only for DirectBuffers launches. Rectangles are mapped relative to Unity's view in the window; an unknown position blanks the frame. The pusher state follows the accepted native launch, the fallback does not replace a waiting launch's options, and LaunchAsync completes even if the follow-up throws. OpenGLES3 re-measured on Unity 6000.0.84f1 (bottom-up). Per-frame Java/managed allocations removed, stats logging is opt-in, compile-check gains a Unity 2022.2+ config against a local editor. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .gitignore | 3 + CHANGELOG.md | 2 +- Documentation~/index.md | 10 +- Runtime/Bugsee.Runtime.asmdef | 2 +- Runtime/Bugsee.cs | 65 ++++-- Runtime/Capture/BugseeFrameCapturer.cs | 196 ++++++++++-------- Runtime/Internal/UnityCaptureProbe.cs | 38 +++- Runtime/WrapperPolicy/CaptureStartup.cs | 85 +++++++- Runtime/WrapperPolicy/FramePushPolicy.cs | 28 ++- Runtime/WrapperPolicy/FrameRedaction.cs | 50 ++++- Runtime/WrapperPolicy/UnityCapturePolicy.cs | 26 ++- .../BugseeHarness/BugseeSampleBootstrap.cs | 13 +- Tests~/WrapperPolicy/CaptureStartupTests.cs | 84 +++++++- Tests~/WrapperPolicy/FramePushPolicyTests.cs | 47 ++++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 8 +- .../WrapperPolicy/UnityCapturePolicyTests.cs | 32 ++- .../Runtime/Bugsee.Runtime.csproj | 11 +- Tools~/scripts/compile-check.sh | 23 ++ 18 files changed, 577 insertions(+), 146 deletions(-) diff --git a/.gitignore b/.gitignore index d2dd47b..0edea83 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ Tools~/CompileCheck/**/obj/ *.apk *.aab *.app + +Tools~/CompileCheck/Runtime/obj-unity-capture/ +Tools~/CompileCheck/Runtime/bin-unity-capture/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d563eb9..43b9f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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, no file, no disk writes), one request in flight with frames dropped meanwhile, throttled to `TargetFps` (default 5). **Supported on Unity 2022.2 and newer (2022.3 LTS, 6000.x); not on 2021.3 or 2022.1**, which lack `AndroidJNI.NewDirectByteBuffer`: 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/Vulkan, a start failure) the wrapper replaces `DirectBuffers` with the default capture (`VideoMode.V2`) before the native SDK launches, with one warning; 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 OpenGLES3 (emulator) and Vulkan (Galaxy Tab S5e); on another graphics API it is unverified and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). +- 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 and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index fda8297..ce36e4c 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -234,9 +234,9 @@ Full product documentation: [docs.bugsee.com/sdk/unity](https://docs.bugsee.com/ Not benchmarked beyond one tablet; do not enable it in production without measuring. Nothing here changes unless you opt in: by default the Android SDK captures the window itself (`VideoMode.V2`), and the capturer only runs for a launch that asks for `VideoMode.DirectBuffers` (or an explicit `Enable()` call). -**Unity versions.** Supported: Unity 2022.2.0b1 and newer (that is the first version with `AndroidJNI.NewDirectByteBuffer`, checked against Unity's reference source: 2022.2.0b1, all of 2022.3 LTS and 6000.x have it; 2022.2.0a1, 2022.1 and 2021.3 do not), Android player builds, with the Screen Capture module. **Not supported: Unity 2021.3 and 2022.1.** The only alternatives there (a Java `byte[]` per frame, or a file) were measured and rejected (about 0.9 s per 1 MB frame, or disk writes). The minimum is one constant, `UnityCapturePolicy.MinimumUnityVersion`, plus the `BUGSEE_UNITY_CAPTURE` version define in `Bugsee.Runtime.asmdef`. +**Unity versions.** Supported: release builds (channel `f` or `p`) of Unity 2022.2.0f1 and newer, Android player builds, with the Screen Capture module. `AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11 (checked against Unity's reference source: 2022.2.0a1, 2022.1 and 2021.3 do not have it; 2022.2.0b1, all of 2022.3 LTS and 6000.x do), but alpha and beta builds are treated as unsupported. **Not supported: Unity 2021.3 and 2022.1.** The only alternatives there (a Java `byte[]` per frame, or a file) were measured and rejected (about 0.9 s per 1 MB frame, or disk writes). The minimum is one constant, `UnityCapturePolicy.MinimumUnityVersion`, plus the `BUGSEE_UNITY_CAPTURE` version define (`2022.2.0`) in `Bugsee.Runtime.asmdef`. -**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: Unity older than 2022.2.0b1, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch (then the wrapper relaunches with the default capture). Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. +**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: Unity older than 2022.2.0b1, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are measured; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch was accepted (then the wrapper relaunches with the default capture, unless a newer Launch/Relaunch is waiting, which then decides). The capturer follows what the native SDK actually did: an ignored Launch (SDK already running) or a refused one leaves it alone, and an accepted one is read back from the SDK's effective launch options. Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. ```csharp // using Bugsee.Capture; @@ -246,14 +246,14 @@ Bugsee.Launch(token, new AndroidLaunchOptions { CaptureVideo = true, CaptureVide #endif ``` -How it works: at the end of each due frame it calls `ScreenCapture.CaptureScreenshotIntoRenderTexture`, scales into a small RenderTexture and reads it back with `AsyncGPUReadback` (never a synchronous read). One request is in flight at a time; frames that come due meanwhile are dropped, not queued. The readback is copied once into a persistent native buffer, secure rectangles are blanked in place, and a push thread wraps that memory as a Java direct `ByteBuffer` for `VideoFrameConsumer.addFrame` with the SDK's own clock as the timestamp. No Java array and no file: frames never touch disk. `BugseeFrameCapturer.Stats` and a log line every 10 s show requested, pushed, failed and dropped counts. +How it works: at the end of each due frame it calls `ScreenCapture.CaptureScreenshotIntoRenderTexture`, scales into a small RenderTexture and reads it back with `AsyncGPUReadback` (never a synchronous read). One request is in flight at a time; frames that come due meanwhile are dropped, not queued. The readback is copied once into a persistent native buffer, secure rectangles are blanked in place, and a push thread wraps that memory as a Java direct `ByteBuffer` for `VideoFrameConsumer.addFrame` with the SDK's own clock as the timestamp. No Java array, and the wrapper writes no frame to disk (the SDK itself still writes its recorded video as usual). `BugseeFrameCapturer.Stats` shows requested, pushed, failed and dropped counts; `BugseeFrameCapturerOptions.LogStats` also logs them every 10 s. `Enable()` called off the main thread is queued and returns true before it ran; check `IsEnabled`. What to know before using it: - Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. - It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. -- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (the activity window size, top-left origin) and scaled to the frame. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. -- Row order of the readback was measured for OpenGLES3 (Android 16 arm64 emulator: BottomUp) and Vulkan (Galaxy Tab S5e, Android 11, Adreno 615: TopDown). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. +- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (top-left origin). They are mapped relative to where Unity's view sits in the window (its position and size, re-read about once a second, so a status bar, cutout or multi-window offset is accounted for) and scaled to the frame; if that position cannot be read, the whole frame is blanked while a rectangle is registered. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. +- Row order of the readback was measured for Vulkan (Galaxy Tab S5e, Android 11, Adreno 615, Unity 6000.0.84f1: TopDown) and for OpenGLES3 (Android 16 arm64 emulator, swiftshader, Unity 6000.0.84f1: BottomUp, with a red-over-blue pattern; no GLES3 device was measured). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (not reachable on supported versions). After 5 failed readbacks in a row it turns itself off. - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. - Measured once, on a Galaxy Tab S5e (Android 11, Vulkan, Unity 6000.0.84f1 IL2CPP development build, the sample scene, 400x640 capture at `TargetFps` 5, about 100 s per run, pusher on versus the SDK's default capture): about 4.6 frames/s pushed, 0 failures, 2-3 frames dropped in flight; readback latency 81-90 ms; `addFrame` 18-22 ms on the push thread. Unity frame rate 38.3 versus 38.6 fps, frame time p50 33.1 ms and p95 33.1 ms in both. The default capture's `BugseeScreenCap` thread (about 6-13% of a core) is replaced by the push thread (about 3-7%); main thread about 47-57% of a core either way; total PSS 383-391 MB versus 389-400 MB. One device and one light scene, not a benchmark of heavier games. diff --git a/Runtime/Bugsee.Runtime.asmdef b/Runtime/Bugsee.Runtime.asmdef index bca6034..c723f87 100644 --- a/Runtime/Bugsee.Runtime.asmdef +++ b/Runtime/Bugsee.Runtime.asmdef @@ -17,7 +17,7 @@ }, { "name": "Unity", - "expression": "2022.2", + "expression": "2022.2.0", "define": "BUGSEE_UNITY_CAPTURE" } ], diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index b993715..2a3b50e 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Threading; using System.Threading.Tasks; using Bugsee.Contracts.Appearance; using Bugsee.Contracts.Exchange; @@ -67,7 +68,10 @@ public static void Launch(string appToken, Dictionary options = MainThreadDispatcher.Ensure(); options = ApplyCapturePolicy(options, out bool startPusher); if (Bridge is ILifecycleResultBridge bridge) - bridge.Launch(appToken, options, accepted => AfterLaunchResult(accepted, startPusher, options)); + { + Interlocked.Increment(ref _pendingLaunches); + bridge.Launch(appToken, options, accepted => AfterLaunchResultCounted(accepted, startPusher, options)); + } else { Bridge.Launch(appToken, options); @@ -84,7 +88,10 @@ public static void Relaunch(Dictionary options = null) { options = ApplyCapturePolicy(options, out bool startPusher); if (Bridge is ILifecycleResultBridge bridge) - bridge.Relaunch(options, accepted => AfterLaunchResult(accepted, startPusher, options)); + { + Interlocked.Increment(ref _pendingLaunches); + bridge.Relaunch(options, accepted => AfterLaunchResultCounted(accepted, startPusher, options)); + } else { Bridge.Relaunch(options); @@ -97,7 +104,7 @@ public static void Relaunch(Dictionary options = null) // without frames. See UnityCapturePolicy. static Dictionary ApplyCapturePolicy(Dictionary options, out bool startPusher) { - var effective = UnityCapturePolicy.Apply(options, UnityCaptureProbe.Current(), out VideoModeDecision decision); + var effective = UnityCapturePolicy.Apply(options, UnityCaptureProbe.Current, out VideoModeDecision decision); startPusher = decision.UseUnityCapture; if (decision.FallbackReason != null) Debug.LogWarning("[Bugsee] VideoMode.DirectBuffers was requested, but Unity-side capture cannot run: " @@ -105,9 +112,19 @@ static Dictionary ApplyCapturePolicy(Dictionary return effective; } - // The options of the last launch that runs the pusher; the fallback relaunch uses them if the pusher stops itself. - static Dictionary _pusherLaunchOptions; + static readonly PusherLaunchState PusherState = new PusherLaunchState(); static bool _pusherFailureHooked; + // Launch/Relaunch requests handed to the native SDK whose answer has not arrived. While one is waiting, a fallback + // relaunch would replace its options in the lifecycle queue, so the fallback is left to that request. + static int _pendingLaunches; + + static bool NoNewerLaunchWaiting() => Volatile.Read(ref _pendingLaunches) == 0; + + static void AfterLaunchResultCounted(bool accepted, bool startPusher, Dictionary options) + { + Interlocked.Decrement(ref _pendingLaunches); + AfterLaunchResult(accepted, startPusher, options); + } // The pusher follows what the native SDK actually did with a launch request, once its answer is known: an ignored or // refused request (e.g. a Launch while the SDK is running) leaves it alone, an accepted one is read back from the @@ -115,22 +132,19 @@ static Dictionary ApplyCapturePolicy(Dictionary // relaunched with the default capture. All of it runs on the main thread. static void AfterLaunchResult(bool accepted, bool startPusher, Dictionary options) { - if (accepted && startPusher) _pusherLaunchOptions = options; - if (startPusher && !_pusherFailureHooked) + if (!_pusherFailureHooked) { _pusherFailureHooked = true; Capture.BugseeFrameCapturer.Failed += () => MainThreadDispatcher.Run(() => - { - if (_pusherLaunchOptions == null) return; - var failedOptions = _pusherLaunchOptions; - _pusherLaunchOptions = null; - CaptureStartup.OnPusherFailed(failedOptions, Bridge.Relaunch, message => Debug.LogWarning("[Bugsee] " + message)); - }); + CaptureStartup.OnPusherFailed(PusherState, NoNewerLaunchWaiting, Bridge.Relaunch, + message => Debug.LogWarning("[Bugsee] " + message))); } CaptureStartup.OnLaunchResult( accepted, startPusher, options, + PusherState, + NoNewerLaunchWaiting, () => IsStartingOrLaunched(Bridge.GetStatus()), EffectiveVideoMode, () => Capture.BugseeFrameCapturer.Enable(Capture.BugseeFrameCapturer.DefaultOptions), @@ -161,7 +175,18 @@ static void AfterLaunchResult(bool accepted, bool startPusher, Dictionary - public static void Stop(Action completion = null) => Bridge.Stop(completion); + public static void Stop(Action completion = null) + { + ForgetPusherSession(); + Bridge.Stop(completion); + } + + // A stopped session has no DirectBuffers state: a later pusher failure must not relaunch it with old options. + static void ForgetPusherSession() + { + PusherState.OnStopped(); + Capture.BugseeFrameCapturer.Disable(); + } /// /// that completes with the SDK's answer: true when it accepted the launch, false @@ -182,10 +207,12 @@ public static Task LaunchAsync(string appToken, Dictionary var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { + Interlocked.Increment(ref _pendingLaunches); bridge.Launch(appToken, options, accepted => { - AfterLaunchResult(accepted, startPusher, options); - result.TrySetResult(accepted); + // The task must complete even if the capture follow-up throws (the lifecycle machine swallows it). + try { AfterLaunchResultCounted(accepted, startPusher, options); } + finally { result.TrySetResult(accepted); } }); } else @@ -204,10 +231,11 @@ public static Task RelaunchAsync(Dictionary options = null var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { + Interlocked.Increment(ref _pendingLaunches); bridge.Relaunch(options, accepted => { - AfterLaunchResult(accepted, startPusher, options); - result.TrySetResult(accepted); + try { AfterLaunchResultCounted(accepted, startPusher, options); } + finally { result.TrySetResult(accepted); } }); } else @@ -229,6 +257,7 @@ public static Task RelaunchAsync(Dictionary options = null /// public static Task StopAsync() { + ForgetPusherSession(); var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (Bridge is ILifecycleResultBridge bridge) { diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index dd4d8b5..4ba0c4f 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -35,6 +35,9 @@ public sealed class BugseeFrameCapturerOptions /// public VideoFrameRowOrder? RowOrder; + /// Log the capture counters and timings every 10 seconds (off by default). + public bool LogStats; + /// Log the colours of the first and last readback rows (and the graphics API) for the first few frames, to check orientation. public bool LogOrientationProbe; } @@ -77,7 +80,9 @@ public sealed class BugseeFrameCapturer : MonoBehaviour /// Starts the pusher. Returns false (and does nothing) off Android, in the Editor, on Unity older than 2022.2 /// (no AndroidJNI.NewDirectByteBuffer), without the Screen Capture module, or when the device cannot do GPU /// readback. Call after or around Bugsee.Launch with VideoMode.DirectBuffers; frames are skipped until the SDK - /// is launched. Main thread only. + /// is launched. Called off the main thread it is queued and returns true before it has run: whether it actually + /// started is then visible through (and a failed start makes the wrapper relaunch with the + /// default capture when the launch asked for DirectBuffers). /// public static bool Enable(BugseeFrameCapturerOptions options = null) { @@ -154,13 +159,14 @@ static bool EnableReadback(BugseeFrameCapturerOptions options) return true; } + const string BugseeClass = "com/bugsee/library/Bugsee"; const string ConsumerClass = "com/bugsee/library/contracts/capture/consumers/VideoFrameConsumer"; const string RowOrderClass = ConsumerClass + "$RowOrder"; const string AddFrameSignature = "(JILjava/nio/ByteBuffer;IIIL" + RowOrderClass + ";)V"; const string TimeUtilsClass = "com/bugsee/library/shared/util/TimeUtils"; const float StatsLogIntervalSeconds = 10f; - const double ConsumerRetrySeconds = 2.0; - const double ConsumerWarnSeconds = 30.0; + const double ViewRegionRefreshSeconds = 1.0; + const int AndroidContentViewId = 16908290; // android.R.id.content const int ProbeFrames = 3; static readonly WaitForEndOfFrame EndOfFrame = new WaitForEndOfFrame(); @@ -174,26 +180,29 @@ static bool EnableReadback(BugseeFrameCapturerOptions options) int _captureW, _captureH; long _requestTimestampMs; - // Size of the screen at request time, in native window pixels (what secure rectangles are registered in). - int _lastScreenW, _lastScreenH, _nativeW, _nativeH, _requestNativeW, _requestNativeH; + // Where Unity's view sits in the window, in native window pixels (what secure rectangles are registered in): + // refreshed about once a second and when the Unity screen size changes. Width 0 means unknown (fail closed). + int _lastScreenW, _lastScreenH; + SourceRegion _viewRegion, _requestRegion; + double _nextViewRegion; int[] _rects; int[] _requestRects; // rectangles registered when the capture started int _requestRectCount; int _probeFrames; - // Timing for the stats line (average ms): request to readback callback, copy into the Java buffer, addFrame. + // Timing for the stats line (average ms): request to readback callback, addFrame. long _requestTicks, _latencySum, _addSum, _timedFrames; // The readback callback (main thread) copies the frame into a persistent native buffer, masks it in place, and - // hands it to the push thread, which wraps the same memory as a Java direct ByteBuffer for addFrame (no Java + // hands it to the push thread, which passes the same memory to addFrame as a Java direct ByteBuffer (no Java // array, no file). The SDK copies before addFrame returns. Only one request is in flight, so neither side - // touches the buffer while the other does. - // Allocated once per size. + // touches the buffer while the other does. Allocated once per size, with one cached global ref to the ByteBuffer. NativeArray _native; IntPtr _nativePtr; int _nativeLength; - readonly object _nativeLock = new object(); - IntPtr _consumer; // global ref to the VideoFrameConsumer + IntPtr _directBuffer; // global ref to the ByteBuffer over _native + IntPtr _bugseeClass, _isBlackoutMethod, _getConsumerMethod; // Bugsee.isBlackout() / getVideoFrameConsumer() + IntPtr _consumer; // global ref to the VideoFrameConsumer in use IntPtr _addFrameMethod; IntPtr _topDown, _bottomUp; // global refs to the RowOrder constants IntPtr _timeClass, _timeMethod; // TimeUtils.monotonicWallTimeMillis(), the SDK's own clock @@ -201,16 +210,16 @@ static bool EnableReadback(BugseeFrameCapturerOptions options) IntPtr _elapsedClass, _elapsedMethod; readonly jvalue[] _args = new jvalue[7]; readonly jvalue[] _noArgs = new jvalue[0]; - bool _wasLaunched; bool _warnedMode; bool _paused; bool _releaseDone; float _nextStatsLog; - double _nextConsumerTry, _nextConsumerWarn; Thread _worker; readonly SemaphoreSlim _signal = new SemaphoreSlim(0); volatile bool _stopWorker; + volatile bool _pushing; + int _nativeReleased; int _pushW, _pushH; VideoFrameRowOrder _pushOrder; @@ -230,9 +239,16 @@ bool Begin(BugseeFrameCapturerOptions options) IntPtr cls = FindClassOrClear(ConsumerClass); _addFrameMethod = cls == IntPtr.Zero ? IntPtr.Zero : AndroidJNI.GetMethodID(cls, "addFrame", AddFrameSignature); AndroidJNI.DeleteLocalRef(cls); - const bool bufferMethodsMissing = false; - if (bufferMethodsMissing - || _addFrameMethod == IntPtr.Zero || _topDown == IntPtr.Zero || _bottomUp == IntPtr.Zero) + IntPtr bugsee = FindClassOrClear(BugseeClass); + if (bugsee != IntPtr.Zero) + { + _bugseeClass = AndroidJNI.NewGlobalRef(bugsee); + _isBlackoutMethod = AndroidJNI.GetStaticMethodID(bugsee, "isBlackout", "()Z"); + _getConsumerMethod = AndroidJNI.GetStaticMethodID(bugsee, "getVideoFrameConsumer", "()L" + ConsumerClass + ";"); + AndroidJNI.DeleteLocalRef(bugsee); + } + if (_addFrameMethod == IntPtr.Zero || _topDown == IntPtr.Zero || _bottomUp == IntPtr.Zero + || _isBlackoutMethod == IntPtr.Zero || _getConsumerMethod == IntPtr.Zero) { // On an SDK older than 7.4.1 the lookups above leave a pending NoSuchMethodError/ClassNotFoundException. AndroidJNI.ExceptionClear(); @@ -304,7 +320,6 @@ long NowMs() => _timeMethod != IntPtr.Zero ? AndroidJNI.CallStaticLongMethod(_timeClass, _timeMethod, _noArgs) : _clockOffsetMs + AndroidJNI.CallStaticLongMethod(_elapsedClass, _elapsedMethod, _noArgs); - void Stop() { _enabled = false; @@ -318,7 +333,7 @@ void Update() { if (_enabled) { - if (Time.unscaledTime >= _nextStatsLog) + if (_options.LogStats && Time.unscaledTime >= _nextStatsLog) { _nextStatsLog = Time.unscaledTime + StatsLogIntervalSeconds; var s = ReadStats(); @@ -357,8 +372,10 @@ System.Collections.IEnumerator CaptureLoop() } catch (Exception ex) { - // An exception would end the coroutine and silently stop capture. + // An exception would end the coroutine and silently stop capture; count it as a failed capture so a + // persistent problem turns the pusher off (and the wrapper falls back to the default capture). Debug.LogWarning("[BugseeFrameCapturer] tick failed: " + ex.Message); + _policy.RecordFailure(); } } } @@ -368,32 +385,31 @@ void Tick() double now = Time.realtimeSinceStartupAsDouble; if (_paused || !_policy.IsDue(now)) return; - // JNI calls only once per due frame, not on every game frame. - bool launched = Bugsee.IsLaunched; - bool stopped = !launched || Bugsee.IsBlackout; - if (launched && (!_wasLaunched || _consumer == IntPtr.Zero) && now >= _nextConsumerTry && !_policy.HasInFlight) + // Raw JNI, no managed allocation: the consumer exists only while the SDK is launched, and a relaunch hands out + // a new one, so the cached reference is replaced when it differs (only while nothing is in flight). + IntPtr current = AndroidJNI.CallStaticObjectMethod(_bugseeClass, _getConsumerMethod, _noArgs); + if (ClearPendingException()) current = IntPtr.Zero; + bool launched = current != IntPtr.Zero; + if (launched && !_policy.HasInFlight && (_consumer == IntPtr.Zero || !AndroidJNI.IsSameObject(_consumer, current))) { - _nextConsumerTry = now + ConsumerRetrySeconds; - RefreshConsumer(); - if (_consumer == IntPtr.Zero && now >= _nextConsumerWarn) - { - _nextConsumerWarn = now + ConsumerWarnSeconds; - Debug.LogWarning("[BugseeFrameCapturer] the SDK has no video frame consumer yet (not launched with video capture?); retrying."); - } + if (_consumer != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(_consumer); + _consumer = AndroidJNI.NewGlobalRef(current); + CheckLaunchMode(); } - _wasLaunched = launched; - stopped |= _consumer == IntPtr.Zero; + AndroidJNI.DeleteLocalRef(current); + bool blackout = AndroidJNI.CallStaticBooleanMethod(_bugseeClass, _isBlackoutMethod, _noArgs); + if (ClearPendingException()) blackout = true; // cannot prove it is not blacked out: fail closed + bool stopped = !launched || blackout || _consumer == IntPtr.Zero; if (_policy.TryBegin(now, stopped) != FramePushDecision.Request) return; try { - UpdateNativeSize(); + UpdateViewRegion(now); if (!EnsureBuffers()) { _policy.Complete(false); return; } _requestTimestampMs = NowMs(); _requestTicks = System.Diagnostics.Stopwatch.GetTimestamp(); - _requestNativeW = _nativeW; - _requestNativeH = _nativeH; + _requestRegion = _viewRegion; _requestRectCount = SecureRectRegistry.Instance.CopyRects(0, ref _requestRects); ScreenCapture.CaptureScreenshotIntoRenderTexture(_fullRt); Graphics.Blit(_fullRt, _smallRt); @@ -406,41 +422,36 @@ void Tick() } } - // Secure rectangles are native window pixels, which differ from Screen.width/height when Unity renders at a - // reduced resolution. Re-read the activity window size only when the Unity screen size changes. - void UpdateNativeSize() + // Secure rectangles are native window pixels, and Unity's view need not fill the window (status bar, cutout, + // multi-window, reduced render resolution), so the rectangles are mapped relative to the view's position in the + // window. Re-read about once a second and when the Unity screen size changes; unknown if it cannot be read. + void UpdateViewRegion(double now) { - if (Screen.width == _lastScreenW && Screen.height == _lastScreenH && _nativeW > 0) return; + if (Screen.width == _lastScreenW && Screen.height == _lastScreenH && now < _nextViewRegion && _viewRegion.IsKnown) return; _lastScreenW = Screen.width; _lastScreenH = Screen.height; - int w = 0, h = 0; + _nextViewRegion = now + ViewRegionRefreshSeconds; try { using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = player.GetStatic("currentActivity")) - using (var window = activity.Call("getWindow")) - using (var decor = window.Call("getDecorView")) + using (var content = activity.Call("findViewById", AndroidContentViewId)) + using (var view = content.Call("getChildAt", 0)) { - w = decor.Call("getWidth"); - h = decor.Call("getHeight"); + int[] location = new int[2]; + view.Call("getLocationInWindow", location); + _viewRegion = new SourceRegion(location[0], location[1], view.Call("getWidth"), view.Call("getHeight")); } } catch (Exception) { - // fall back to the Unity size below + _viewRegion = default; // unknown: masking fails closed } - _nativeW = w > 0 ? w : Screen.width; - _nativeH = h > 0 ? h : Screen.height; } - void RefreshConsumer() + // Once per consumer: warn if the SDK is not launched with DirectBuffers (it then ignores pushed frames). + void CheckLaunchMode() { - if (_consumer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_consumer); _consumer = IntPtr.Zero; } - using (var bugsee = new AndroidJavaClass("com.bugsee.library.Bugsee")) - using (var consumer = bugsee.CallStatic("getVideoFrameConsumer")) - { - if (consumer != null) _consumer = AndroidJNI.NewGlobalRef(consumer.GetRawObject()); - } if (_warnedMode) return; _warnedMode = true; IReadOnlyDictionary launch = Bugsee.GetLaunchOptions(); @@ -451,11 +462,9 @@ void RefreshConsumer() Debug.LogWarning("[BugseeFrameCapturer] the SDK is not launched with VideoMode.DirectBuffers; pushed frames are ignored."); } - bool HasPixelBuffer => - _nativePtr != IntPtr.Zero; + bool HasPixelBuffer => _nativePtr != IntPtr.Zero; - // The frame buffer as a span (managed array, or the persistent native memory), so masking and probing do not - // care which one is in use. + // The frame buffer as a span over the persistent native memory. Span PixelSpan() { unsafe { return new Span((void*)_nativePtr, _nativeLength); } @@ -481,6 +490,16 @@ bool EnsureBuffers() _native = new NativeArray(bytes, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); unsafe { _nativePtr = (IntPtr)Unity.Collections.LowLevel.Unsafe.NativeArrayUnsafeUtility.GetUnsafePtr(_native); } _nativeLength = bytes; + IntPtr local; + unsafe { local = AndroidJNI.NewDirectByteBuffer((byte*)_nativePtr, bytes); } + if (local == IntPtr.Zero) + { + ClearPendingException(); + ReleaseNativeBuffer(); + return false; + } + _directBuffer = AndroidJNI.NewGlobalRef(local); + AndroidJNI.DeleteLocalRef(local); return true; } @@ -500,18 +519,17 @@ void OnReadback(AsyncGPUReadbackRequest request) if (_options.LogOrientationProbe && _probeFrames < ProbeFrames) LogProbe(pixels); - // The SDK does not mask pushed frames: blank the registered secure rectangles first. If the row order is - // unverified the mask could land on mirrored rows and leak the real area, so blank the whole frame instead. - // Mask what was secure when the capture started as well as what is secure now. Live rectangles are in the - // window size of now (it may have changed since the request), request-time ones in the size of the request. - UpdateNativeSize(); + // The SDK does not mask pushed frames: blank the registered secure rectangles first. Mask what was secure when + // the capture started as well as what is secure now, each mapped with the view position of its own moment. If + // the row order is unverified the mask could land on mirrored rows and leak the real area, and if a view + // position is unknown the rectangles cannot be placed: both blank the whole frame. + UpdateViewRegion(Time.realtimeSinceStartupAsDouble); int count = SecureRectRegistry.Instance.CopyRects(0, ref _rects); if (count > 0 || _requestRectCount > 0) { if (_rowOrderVerified) - FrameRedaction.BlankRequestAndLive(pixels, _captureW, _captureH, _rowOrder, - _requestRects, _requestRectCount, _requestNativeW, _requestNativeH, - _rects, count, _nativeW, _nativeH); + FrameRedaction.MaskFrame(pixels, _captureW, _captureH, _rowOrder, + _requestRects, _requestRectCount, _requestRegion, _rects, count, _viewRegion); else FrameRedaction.BlankAll(pixels, _captureW, _captureH); } @@ -519,6 +537,7 @@ void OnReadback(AsyncGPUReadbackRequest request) _pushW = _captureW; _pushH = _captureH; _pushOrder = _rowOrder; + _pushing = true; _signal.Release(); } @@ -541,8 +560,8 @@ void LogProbe(Span pixels) + " lastRow rgba=" + pixels[b] + "," + pixels[b + 1] + "," + pixels[b + 2] + "," + pixels[b + 3]); } - // Push thread: one frame per signal. addFrame converts synchronously on this thread, so the direct buffer can - // be reused as soon as it returns. + // Push thread: one frame per signal. addFrame converts synchronously on this thread, so the buffer can be reused + // as soon as it returns. void PushLoop() { AndroidJNI.AttachCurrentThread(); @@ -552,11 +571,16 @@ void PushLoop() { _signal.Wait(); if (_stopWorker) break; - _policy.Complete(PushOne()); + bool ok = PushOne(); + _pushing = false; + _policy.Complete(ok); + // A destroy that arrived during the push left the release to us. + if (_stopWorker) break; } } finally { + if (_stopWorker) ReleaseNative(); AndroidJNI.DetachCurrentThread(); } } @@ -567,23 +591,18 @@ bool PushOne() { int length = _pushW * _pushH * FramePushPolicy.PixelStride; // The SDK reads width * height * stride bytes (stride 4) from the buffer base and needs capacity >= that; - // the native buffer was sized exactly so. It copies before addFrame returns, and the buffer is not - // reused until Complete() below lets the next request start. - if (length > _nativeLength) return false; - IntPtr buffer; - unsafe { buffer = AndroidJNI.NewDirectByteBuffer((byte*)_nativePtr, length); } - if (buffer == IntPtr.Zero) { ClearPendingException(); return false; } + // the native buffer was sized exactly so. + if (length > _nativeLength || _directBuffer == IntPtr.Zero || _consumer == IntPtr.Zero) return false; long t1 = System.Diagnostics.Stopwatch.GetTimestamp(); _args[0].j = _requestTimestampMs; _args[1].i = 0; // display id - _args[2].l = buffer; + _args[2].l = _directBuffer; _args[3].i = _pushW; _args[4].i = _pushH; _args[5].i = FramePushPolicy.PixelStride; _args[6].l = _pushOrder == VideoFrameRowOrder.BottomUp ? _bottomUp : _topDown; AndroidJNI.CallVoidMethod(_consumer, _addFrameMethod, _args); long t2 = System.Diagnostics.Stopwatch.GetTimestamp(); - AndroidJNI.DeleteLocalRef(buffer); Interlocked.Add(ref _addSum, t2 - t1); Interlocked.Increment(ref _timedFrames); return !ClearPendingException(); @@ -612,23 +631,23 @@ void ReleaseRenderTextures() void ReleaseNativeBuffer() { + if (_directBuffer != IntPtr.Zero) { AndroidJNI.DeleteGlobalRef(_directBuffer); _directBuffer = IntPtr.Zero; } _nativePtr = IntPtr.Zero; _nativeLength = 0; if (_native.IsCreated) _native.Dispose(); } - + // Safe to call from the main thread when no push is running, or from the push thread as it exits. void ReleaseNative() { + // Both the main thread and the push thread may get here when the capturer is destroyed: only the first releases. + if (Interlocked.Exchange(ref _nativeReleased, 1) == 1) return; ReleaseNativeBuffer(); - lock (_nativeLock) - { - IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass }; - for (int i = 0; i < refs.Length; i++) - if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); - _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = IntPtr.Zero; - _timeMethod = _elapsedMethod = IntPtr.Zero; - } + IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass, _bugseeClass }; + for (int i = 0; i < refs.Length; i++) + if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); + _consumer = _topDown = _bottomUp = _timeClass = _elapsedClass = _bugseeClass = IntPtr.Zero; + _timeMethod = _elapsedMethod = _isBlackoutMethod = _getConsumerMethod = IntPtr.Zero; } void OnDestroy() @@ -638,8 +657,9 @@ void OnDestroy() _signal.Release(); if (_instance == this) _instance = null; ReleaseRenderTextures(); - // A push still running owns the references until it finishes; the worker detaches on exit. - if (_policy == null || !_policy.HasInFlight) ReleaseNative(); + // A push still running owns the buffer and references until it finishes; the push thread then releases them. + // Otherwise (also with a readback still pending: its callback finds no buffer and gives up) release now. + if (!_pushing) ReleaseNative(); } #else void Stop() { _enabled = false; } diff --git a/Runtime/Internal/UnityCaptureProbe.cs b/Runtime/Internal/UnityCaptureProbe.cs index 8144d71..e96edd8 100644 --- a/Runtime/Internal/UnityCaptureProbe.cs +++ b/Runtime/Internal/UnityCaptureProbe.cs @@ -3,10 +3,38 @@ namespace Bugsee.Internal { - /// Reads what UnityCapturePolicy needs from the running player. + /// + /// Reads what UnityCapturePolicy needs from the running player. SystemInfo may only be used on the main thread, so the + /// values are read once on it at startup (and lazily on the main thread if that did not run) and a launch from any + /// other thread uses the cached copy. + /// static class UnityCaptureProbe { + static readonly object Gate = new object(); + static UnityCaptureEnvironment _cached; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void Bootstrap() + { + Current(); + } + public static UnityCaptureEnvironment Current() + { + var env = _cached; + if (env == null && MainThreadDispatcher.IsMainThread) + { + env = Read(); + lock (Gate) _cached = env; + } + // Off the main thread before the startup read ran: report nothing usable, so the launch falls back. + env = env ?? _cached ?? Unavailable(); + // The one value that can change after startup, and is a plain field read. + env.RowOrderDeclared = global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions.RowOrder.HasValue; + return env; + } + + static UnityCaptureEnvironment Read() { return new UnityCaptureEnvironment { @@ -18,9 +46,13 @@ public static UnityCaptureEnvironment Current() ScreenCaptureModule = false, #endif AsyncReadbackSupported = SystemInfo.supportsAsyncGPUReadback, - GraphicsApi = SystemInfo.graphicsDeviceType.ToString(), - RowOrderDeclared = global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions.RowOrder.HasValue + GraphicsApi = SystemInfo.graphicsDeviceType.ToString() }; } + + static UnityCaptureEnvironment Unavailable() + { + return new UnityCaptureEnvironment { IsAndroidPlayer = true, UnityVersion = null }; + } } } diff --git a/Runtime/WrapperPolicy/CaptureStartup.cs b/Runtime/WrapperPolicy/CaptureStartup.cs index fbdf845..adbcaee 100644 --- a/Runtime/WrapperPolicy/CaptureStartup.cs +++ b/Runtime/WrapperPolicy/CaptureStartup.cs @@ -11,12 +11,58 @@ namespace Bugsee.WrapperPolicy /// read back from the effective launch options. If the pusher cannot start, the session is relaunched with the /// default capture. Written against delegates so it can be tested without Unity. /// + /// + /// What the wrapper knows about the running native session's pusher: whether the native SDK runs in DirectBuffers + /// (as it reports it after an accepted launch) and the user's options of that launch, for a fallback relaunch. + /// Cleared when the session stops or a later accepted launch is not DirectBuffers. Thread-safe. + /// + public sealed class PusherLaunchState + { + readonly object _gate = new object(); + bool _active; + Dictionary _lastOptions; + + public bool Active { get { lock (_gate) return _active; } } + public Dictionary LastOptions { get { lock (_gate) return _lastOptions; } } + + public void OnAccepted(VideoMode? effectiveMode, bool startRequested, Dictionary options) + { + bool direct = effectiveMode.HasValue ? effectiveMode.Value == VideoMode.DirectBuffers : startRequested; + lock (_gate) + { + _active = direct; + // A Relaunch without options keeps the previous ones; a session that is not DirectBuffers keeps none. + _lastOptions = direct ? (options ?? _lastOptions) : null; + } + } + + public void OnStopped() + { + lock (_gate) { _active = false; _lastOptions = null; } + } + + /// The pusher failed: the options for the fallback relaunch, or false when no DirectBuffers session is known. Consumes the state. + public bool TakeFailure(out Dictionary options) + { + lock (_gate) + { + options = _lastOptions; + bool was = _active; + _active = false; + _lastOptions = null; + return was; + } + } + } + public static class CaptureStartup { /// Call with the answer of a Launch/Relaunch request, once it is known. /// The native SDK accepted the request (it ran). /// The capture policy decided the request wants the pusher (DirectBuffers kept). /// The options the request used (a fallback relaunch is these with the default mode). + /// Tracks the running session's DirectBuffers state and options. + /// No newer Launch/Relaunch is waiting: a fallback relaunch would otherwise replace its options, so it is skipped and that request decides. /// The native SDK is launching or launched. /// The video mode the native SDK reports in its launch options, or null when unreadable. /// Starts the pusher; main thread only; false when it could not start. @@ -28,6 +74,8 @@ public static void OnLaunchResult( bool accepted, bool startRequested, Dictionary options, + PusherLaunchState state, + Func isLatestRequest, Func isRunning, Func effectiveMode, Func enable, @@ -41,11 +89,25 @@ public static void OnLaunchResult( if (!accepted) { // Ignored or refused: the native session, if any, is unchanged, so the pusher is too. - if (!isRunning()) disable(); + if (!isRunning()) + { + state.OnStopped(); + disable(); + } return; } - VideoMode? mode = effectiveMode(); + VideoMode? mode; + try + { + mode = effectiveMode(); + } + catch (Exception ex) + { + warn("could not read the effective video mode: " + ex.Message); + mode = null; + } + state.OnAccepted(mode, startRequested, options); bool wantPusher = mode.HasValue ? mode.Value == VideoMode.DirectBuffers : startRequested; if (!wantPusher) { @@ -64,17 +126,30 @@ public static void OnLaunchResult( started = false; } if (started) return; + if (!state.TakeFailure(out Dictionary fallbackOptions)) return; + if (!isLatestRequest()) + { + warn("the Unity-side frame pusher could not start, but a newer launch request is waiting; it decides the capture mode."); + return; + } warn("the Unity-side frame pusher could not start; relaunching with the SDK's default capture."); - relaunch(UnityCapturePolicy.WithFallback(options)); + relaunch(UnityCapturePolicy.WithFallback(fallbackOptions ?? options)); }); } - /// The pusher stopped itself after launch (repeated readback failures): same fallback. + /// The pusher stopped itself after launch (repeated readback failures): the same fallback, from the tracked session. public static void OnPusherFailed( - Dictionary options, + PusherLaunchState state, + Func isLatestRequest, Action> relaunch, Action warn) { + if (!state.TakeFailure(out Dictionary options)) return; + if (!isLatestRequest()) + { + warn("the Unity-side frame pusher stopped, but a newer launch request is waiting; it decides the capture mode."); + return; + } warn("the Unity-side frame pusher stopped after repeated failures; relaunching with the SDK's default capture."); relaunch(UnityCapturePolicy.WithFallback(options)); } diff --git a/Runtime/WrapperPolicy/FramePushPolicy.cs b/Runtime/WrapperPolicy/FramePushPolicy.cs index d76a1a5..28579f9 100644 --- a/Runtime/WrapperPolicy/FramePushPolicy.cs +++ b/Runtime/WrapperPolicy/FramePushPolicy.cs @@ -100,6 +100,16 @@ public void Complete(bool pushed) } } + /// A failure outside a request (an exception while preparing one): counts toward the self-disable streak. + public void RecordFailure() + { + lock (_gate) + { + _failed++; + if (++_consecutiveFailures >= _maxConsecutiveFailures) _disabled = true; + } + } + public void Snapshot(out long requested, out long pushed, out long failed, out long droppedInFlight, out long droppedBlackout) { lock (_gate) @@ -142,6 +152,17 @@ public static bool IsReadbackUsable(bool supportsAsyncReadback, bool isVulkan, s } /// Parses "2021.3.45f1" (and "6000.0.1b2") into major, minor, patch. + /// Release channel letter of a Unity version string ("2022.3.62f3" gives 'f'), or '\0'. + public static char UnityChannel(string version) + { + if (string.IsNullOrEmpty(version)) return '\0'; + string[] parts = version.Split('.'); + if (parts.Length < 3) return '\0'; + int i = 0; + while (i < parts[2].Length && char.IsDigit(parts[2][i])) i++; + return i < parts[2].Length ? parts[2][i] : '\0'; + } + public static bool TryParseUnityVersion(string version, out int major, out int minor, out int patch) { major = minor = patch = 0; @@ -165,10 +186,11 @@ public static bool TryMeasuredRowOrder(string graphicsApi, out VideoFrameRowOrde order = VideoFrameRowOrder.BottomUp; switch (graphicsApi) { - // Measured on an Android 16 arm64 emulator, Unity 2021.3.45: the first buffer row was the screen's bottom. - case "OpenGLES3": order = VideoFrameRowOrder.BottomUp; return true; - // Samsung Galaxy Tab S5e (Adreno 615, Android 11), Unity 2021.3.45: the first buffer row was the screen's top. + // Samsung Galaxy Tab S5e (Adreno 615, Android 11), Unity 6000.0.84f1: the first buffer row was the screen's top. case "Vulkan": order = VideoFrameRowOrder.TopDown; return true; + // Android 16 arm64 emulator (swiftshader GLES3), Unity 6000.0.84f1: the first buffer row was the screen's bottom. + // (Unity 2021.3.45 on the same emulator gave the same result, but the capturer does not run there.) + case "OpenGLES3": order = VideoFrameRowOrder.BottomUp; return true; default: return false; } } diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs index 99c4bf3..ce34c51 100644 --- a/Runtime/WrapperPolicy/FrameRedaction.cs +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -8,6 +8,14 @@ namespace Bugsee.WrapperPolicy /// (only blackout is honoured), so the pusher has to, using the rectangles registered with /// Bugsee.AddSecureRectangle. /// + /// Where the captured view sits in the window, in the pixels secure rectangles are registered in. + public readonly struct SourceRegion + { + public readonly int Left, Top, Width, Height; + public SourceRegion(int left, int top, int width, int height) { Left = left; Top = top; Width = width; Height = height; } + public bool IsKnown => Width > 0 && Height > 0; + } + public static class FrameRedaction { /// @@ -21,6 +29,19 @@ public static int BlankRects( Span rgba, int width, int height, VideoFrameRowOrder order, int[] rects, int count, int sourceWidth, int sourceHeight) { + return BlankRects(rgba, width, height, order, rects, count, new SourceRegion(0, 0, sourceWidth, sourceHeight)); + } + + /// + /// Same, for a frame that shows only part of the window: is where the captured view sits + /// in the window (offset and size, in the pixels the rectangles are registered in). Rectangles are shifted by + /// the offset before they are scaled. + /// + public static int BlankRects( + Span rgba, int width, int height, VideoFrameRowOrder order, + int[] rects, int count, SourceRegion region) + { + int sourceWidth = region.Width, sourceHeight = region.Height; if (rects == null || count <= 0 || rects.Length < count * 4) return 0; if (width <= 0 || height <= 0 || sourceWidth <= 0 || sourceHeight <= 0) return 0; if (rgba.Length < (long)width * height * FramePushPolicy.PixelStride) return 0; @@ -29,10 +50,10 @@ public static int BlankRects( for (int i = 0; i < count; i++) { int o = i * 4; - int x0 = Clamp(FloorDiv((long)rects[o] * width, sourceWidth), 0, width); - int y0 = Clamp(FloorDiv((long)rects[o + 1] * height, sourceHeight), 0, height); - int x1 = Clamp(CeilDiv((long)rects[o + 2] * width, sourceWidth), 0, width); - int y1 = Clamp(CeilDiv((long)rects[o + 3] * height, sourceHeight), 0, height); + int x0 = Clamp(FloorDiv((long)(rects[o] - region.Left) * width, sourceWidth), 0, width); + int y0 = Clamp(FloorDiv((long)(rects[o + 1] - region.Top) * height, sourceHeight), 0, height); + int x1 = Clamp(CeilDiv((long)(rects[o + 2] - region.Left) * width, sourceWidth), 0, width); + int y1 = Clamp(CeilDiv((long)(rects[o + 3] - region.Top) * height, sourceHeight), 0, height); if (x1 <= x0 || y1 <= y0) continue; for (int y = y0; y < y1; y++) @@ -59,6 +80,27 @@ public static int BlankRequestAndLive( + BlankRects(rgba, width, height, order, liveRects, liveCount, liveSourceWidth, liveSourceHeight); } + /// + /// Masks a frame: the rectangles registered when the capture started (mapped with the view position of that + /// moment) and those registered when the pixels arrived (mapped with the position of now). Fails closed: if a + /// set has rectangles but its view position is unknown, the whole frame is blanked. Returns true when the + /// whole frame was blanked. + /// + public static bool MaskFrame( + Span rgba, int width, int height, VideoFrameRowOrder order, + int[] requestRects, int requestCount, SourceRegion requestRegion, + int[] liveRects, int liveCount, SourceRegion liveRegion) + { + if ((requestCount > 0 && !requestRegion.IsKnown) || (liveCount > 0 && !liveRegion.IsKnown)) + { + BlankAll(rgba, width, height); + return true; + } + BlankRects(rgba, width, height, order, requestRects, requestCount, requestRegion); + BlankRects(rgba, width, height, order, liveRects, liveCount, liveRegion); + return false; + } + /// Fills the whole frame with opaque black: the fail-closed choice when the row order is unverified and a secure rectangle is registered. public static void BlankAll(Span rgba, int width, int height) { diff --git a/Runtime/WrapperPolicy/UnityCapturePolicy.cs b/Runtime/WrapperPolicy/UnityCapturePolicy.cs index 3864b0c..fdf46d2 100644 --- a/Runtime/WrapperPolicy/UnityCapturePolicy.cs +++ b/Runtime/WrapperPolicy/UnityCapturePolicy.cs @@ -32,8 +32,11 @@ public sealed class VideoModeDecision /// public static class UnityCapturePolicy { - /// First Unity with AndroidJNI.NewDirectByteBuffer: 2022.2.0b1 (all of 2022.3 LTS and 6000.x have it, 2021.3 and 2022.1 do not). - public const string MinimumUnityVersion = "2022.2.0b1"; + /// + /// AndroidJNI.NewDirectByteBuffer first appears in 2022.2.0a11 (2022.2.0a1 and 2022.1 do not have it). Only release + /// builds (channel f or p) of 2022.2 and newer are supported, so the minimum is the first 2022.2 release. + /// + public const string MinimumUnityVersion = "2022.2.0f1"; public const int MinimumMajor = 2022; public const int MinimumMinor = 2; @@ -42,7 +45,9 @@ public static class UnityCapturePolicy public static bool IsSupportedUnityVersion(string unityVersion) { + char channel = FramePushPolicy.UnityChannel(unityVersion); return FramePushPolicy.TryParseUnityVersion(unityVersion, out int major, out int minor, out _) + && (channel == 'f' || channel == 'p') && (major > MinimumMajor || (major == MinimumMajor && minor >= MinimumMinor)); } @@ -90,12 +95,23 @@ public static VideoMode RequestedMode(IDictionary options) public static Dictionary Apply( Dictionary options, UnityCaptureEnvironment env, out VideoModeDecision decision) { - if (options == null || !options.ContainsKey(Options.CaptureVideoMode)) + return Apply(options, () => env, out decision); + } + + /// + /// As above, but the environment is only read (the factory only called) when the options ask for DirectBuffers, + /// so launches that do not use it never touch Unity APIs that must run on the main thread. + /// + public static Dictionary Apply( + Dictionary options, Func environment, out VideoModeDecision decision) + { + if (options == null || !options.ContainsKey(Options.CaptureVideoMode) + || RequestedMode(options) != VideoMode.DirectBuffers) { - decision = new VideoModeDecision { Effective = VideoMode.V2 }; + decision = new VideoModeDecision { Effective = options == null ? VideoMode.V2 : RequestedMode(options) }; return options; } - decision = Decide(RequestedMode(options), env); + decision = Decide(VideoMode.DirectBuffers, environment()); return decision.FallbackReason == null ? options : WithFallback(options); } diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index c2c1dad..c8f6e38 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -112,8 +112,17 @@ public void LaunchSdk() _framePusherRequested = experimentalFramePusher || ReadE2EValue("bugsee_frame_pusher", "BUGSEE_FRAME_PUSHER") == "1"; // "bugsee_frame_pattern" = 1 also paints a red-over-blue test pattern and logs the readback rows. var pattern = ReadE2EValue("bugsee_frame_pattern", "BUGSEE_FRAME_PATTERN") == "1"; - global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions = - new global::Bugsee.Capture.BugseeFrameCapturerOptions { LogOrientationProbe = pattern }; + // "bugsee_frame_row_order" = topdown | bottomup declares the readback row order (for measuring an unmeasured graphics API + // together with the probe; a wrong value only affects where secure rectangles are masked). + var rowOrder = ReadE2EValue("bugsee_frame_row_order", "BUGSEE_FRAME_ROW_ORDER"); + global::Bugsee.Capture.BugseeFrameCapturer.DefaultOptions = new global::Bugsee.Capture.BugseeFrameCapturerOptions + { + LogOrientationProbe = pattern, + LogStats = true, + RowOrder = rowOrder == "topdown" ? global::Bugsee.Contracts.Capture.VideoFrameRowOrder.TopDown + : rowOrder == "bottomup" ? global::Bugsee.Contracts.Capture.VideoFrameRowOrder.BottomUp + : (global::Bugsee.Contracts.Capture.VideoFrameRowOrder?)null + }; var options = new AndroidLaunchOptions { CaptureVideo = true, diff --git a/Tests~/WrapperPolicy/CaptureStartupTests.cs b/Tests~/WrapperPolicy/CaptureStartupTests.cs index 5e1198e..2581ccd 100644 --- a/Tests~/WrapperPolicy/CaptureStartupTests.cs +++ b/Tests~/WrapperPolicy/CaptureStartupTests.cs @@ -17,6 +17,8 @@ sealed class Fake public bool EnableResult = true; public bool EnableThrows; public bool PusherOn; + public bool Latest = true; + public readonly PusherLaunchState State = new PusherLaunchState(); public int Enables, Disables; public readonly List> Relaunches = new List>(); public readonly List Warnings = new List(); @@ -26,7 +28,7 @@ sealed class Fake public void Result(bool accepted, bool startRequested, Dictionary options) { - CaptureStartup.OnLaunchResult(accepted, startRequested, options, + CaptureStartup.OnLaunchResult(accepted, startRequested, options, State, () => Latest, () => Running, () => NativeMode, () => { Enables++; if (EnableThrows) throw new InvalidOperationException("boom"); PusherOn = EnableResult; return EnableResult; }, @@ -153,13 +155,87 @@ public void The_fallback_relaunch_runs_after_the_launch_was_accepted_not_while_i } [Test] - public void A_pusher_that_stops_itself_later_relaunches_with_the_default_capture() + public void A_pusher_that_stops_itself_later_relaunches_with_the_options_of_the_accepted_launch() { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); var relaunches = new List>(); - var warnings = new List(); - CaptureStartup.OnPusherFailed(Mode(VideoMode.DirectBuffers), o => relaunches.Add(new Dictionary(o)), warnings.Add); + CaptureStartup.OnPusherFailed(f.State, () => true, o => relaunches.Add(new Dictionary(o)), f.Warnings.Add); Assert.That(relaunches, Has.Count.EqualTo(1)); Assert.That(relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(relaunches[0]["other"], Is.EqualTo(1)); + } + + [Test] + public void A_pusher_failure_after_the_session_stopped_does_not_relaunch_with_old_options() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + f.State.OnStopped(); + var relaunches = new List>(); + CaptureStartup.OnPusherFailed(f.State, () => true, relaunches.Add, f.Warnings.Add); + Assert.That(relaunches, Is.Empty); + } + + [Test] + public void A_later_accepted_launch_that_is_not_direct_buffers_clears_the_old_options() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + f.NativeMode = VideoMode.V2; + f.Result(true, false, new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.V2 } }); + Assert.That(f.State.Active, Is.False); + var relaunches = new List>(); + CaptureStartup.OnPusherFailed(f.State, () => true, relaunches.Add, f.Warnings.Add); + Assert.That(relaunches, Is.Empty); + } + + [Test] + public void A_relaunch_without_options_that_keeps_direct_buffers_still_gets_the_fallback() + { + // Relaunch(null) keeps the running DirectBuffers; the capture policy had nothing to decide (startRequested false), + // but the native SDK reports DirectBuffers, so the pusher is started, and if it cannot start the session falls back. + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, EnableResult = false }; + f.Result(true, false, null); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + } + + [Test] + public void A_relaunch_without_options_falls_back_with_the_options_of_the_earlier_launch() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + f.EnableResult = false; + f.Result(true, false, null); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0]["other"], Is.EqualTo(1)); + } + + [Test] + public void The_fallback_does_not_replace_the_options_of_a_waiting_newer_launch() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, EnableResult = false, Latest = false }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.Relaunches, Is.Empty); + Assert.That(f.Warnings, Has.Count.EqualTo(2).Or.Count.EqualTo(1)); + // and a pusher that stops itself while a newer request waits leaves it to that request + var g = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + g.Result(true, true, Mode(VideoMode.DirectBuffers)); + var relaunches = new List>(); + CaptureStartup.OnPusherFailed(g.State, () => false, relaunches.Add, g.Warnings.Add); + Assert.That(relaunches, Is.Empty); + } + + [Test] + public void An_exception_while_reading_the_effective_mode_is_survived() + { + var warnings = new List(); + var state = new PusherLaunchState(); + bool enabled = false; + CaptureStartup.OnLaunchResult(true, true, Mode(VideoMode.DirectBuffers), state, () => true, () => true, + () => throw new InvalidOperationException("jni"), () => { enabled = true; return true; }, () => { }, o => { }, warnings.Add, a => a()); + Assert.That(enabled, Is.True, "falls back to what the request asked for"); Assert.That(warnings, Has.Count.EqualTo(1)); } } diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index 96e3d79..71a3eb6 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -147,7 +147,7 @@ public void Native_direct_byte_buffer_gate(string version, bool expected) } [Test] - public void Measured_gles3_is_bottom_up_and_verified() + public void Gles3_measured_on_unity_6000_is_bottom_up_and_verified() { Assert.That(FramePushPolicy.ResolveRowOrder("OpenGLES3", null, out bool verified), Is.EqualTo(VideoFrameRowOrder.BottomUp)); Assert.That(verified, Is.True); @@ -308,6 +308,51 @@ public void Request_rects_keep_the_request_time_window_size_after_a_rotation() Assert.That(Untouched(b, 2, 0) && Untouched(b, 0, 2), Is.True); } + [Test] + public void View_offset_shifts_the_rect_before_scaling() + { + // Window 100x220; the Unity view is 100x200 at (0,20) (status bar above it). A rect at window y 20..60 is the + // view's top 40 px: frame rows 0..4 of a 10x20 frame, not rows 2..6. + var b = White(10, 20); + FrameRedaction.BlankRects(b, 10, 20, VideoFrameRowOrder.TopDown, One(0, 20, 100, 60), 1, new SourceRegion(0, 20, 100, 200)); + for (int row = 0; row < 20; row++) + Assert.That(Blank(b, 3, row, 10), Is.EqualTo(row < 4), "row " + row); + } + + [Test] + public void Rect_outside_the_view_masks_nothing() + { + var b = White(10, 20); + Assert.That(FrameRedaction.BlankRects(b, 10, 20, VideoFrameRowOrder.TopDown, One(0, 0, 100, 20), 1, new SourceRegion(0, 20, 100, 200)), Is.EqualTo(0)); + } + + [Test] + public void Mask_frame_blanks_everything_when_the_view_position_is_unknown_and_a_rect_is_registered() + { + var b = White(); + bool all = FrameRedaction.MaskFrame(b, W, H, VideoFrameRowOrder.TopDown, One(0, 0, 1, 1), 1, default(SourceRegion), null, 0, default(SourceRegion)); + Assert.That(all, Is.True); + for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(b, x, y), Is.True); + + var b2 = White(); + Assert.That(FrameRedaction.MaskFrame(b2, W, H, VideoFrameRowOrder.TopDown, null, 0, default(SourceRegion), null, 0, default(SourceRegion)), Is.False); + Assert.That(Untouched(b2, 0, 0), Is.True, "no rectangles: an unknown position is irrelevant"); + + var b3 = White(); + Assert.That(FrameRedaction.MaskFrame(b3, W, H, VideoFrameRowOrder.TopDown, null, 0, default(SourceRegion), One(0, 0, 2, 1), 1, default(SourceRegion)), Is.True); + } + + [Test] + public void Mask_frame_maps_each_set_with_its_own_region() + { + var b = White(); + FrameRedaction.MaskFrame(b, W, H, VideoFrameRowOrder.TopDown, + One(0, 0, 4, 1), 1, new SourceRegion(0, 0, 4, 4), + One(0, 4, 4, 5), 1, new SourceRegion(0, 4, 4, 4)); + for (int x = 0; x < W; x++) Assert.That(Blank(b, x, 0), Is.True); + Assert.That(Untouched(b, 0, 1), Is.True); + } + [Test] public void Masking_works_in_place_on_memory_that_is_not_a_byte_array() { diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index dc28bcd..0c4961a 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -150,12 +150,12 @@ public void Facade_async_lifecycle_uses_the_result_bridge_and_never_blocks() string facade = File.ReadAllText(RepoFile("Runtime/Bugsee.cs")); string launch = ExtractMethodBody(facade, "public static Task LaunchAsync(string appToken, Dictionary options = null)"); Assert.That(launch, Does.Contain("bridge.Launch(appToken, options, accepted =>")); - Assert.That(launch, Does.Contain("AfterLaunchResult(accepted, startPusher, options);")); - Assert.That(launch, Does.Contain("result.TrySetResult(accepted);")); + Assert.That(launch, Does.Contain("try { AfterLaunchResultCounted(accepted, startPusher, options); }")); + Assert.That(launch, Does.Contain("finally { result.TrySetResult(accepted); }")); string relaunch = ExtractMethodBody(facade, "public static Task RelaunchAsync(Dictionary options = null)"); Assert.That(relaunch, Does.Contain("bridge.Relaunch(options, accepted =>")); - Assert.That(relaunch, Does.Contain("AfterLaunchResult(accepted, startPusher, options);")); - Assert.That(relaunch, Does.Contain("result.TrySetResult(accepted);")); + Assert.That(relaunch, Does.Contain("try { AfterLaunchResultCounted(accepted, startPusher, options); }")); + Assert.That(relaunch, Does.Contain("finally { result.TrySetResult(accepted); }")); string stop = ExtractMethodBody(facade, "public static Task StopAsync()"); Assert.That(stop, Does.Contain("bridge.Stop(")); Assert.That(stop, Does.Contain("TimeoutException")); diff --git a/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs b/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs index 21d5584..727275d 100644 --- a/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs +++ b/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs @@ -43,8 +43,9 @@ public void Old_or_unknown_unity_falls_back_to_the_default_capture(string versio Assert.That(d.FallbackReason, Does.Contain(UnityCapturePolicy.MinimumUnityVersion)); } - [TestCase("2022.2.0b1")] + [TestCase("2022.2.0f1")] [TestCase("2022.3.62f3")] + [TestCase("2022.3.1p1")] [TestCase("6000.0.84f1")] public void The_minimum_version_and_everything_above_it_is_supported(string version) { @@ -54,6 +55,35 @@ public void The_minimum_version_and_everything_above_it_is_supported(string vers Assert.That(UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env).UseUnityCapture, Is.True); } + [TestCase("2022.2.0a11")] + [TestCase("2022.2.0b1")] + [TestCase("2023.1.0a5")] + [TestCase("6000.0.1b2")] + public void Pre_release_builds_are_not_supported(string version) + { + var env = Supported(); + env.UnityVersion = version; + Assert.That(UnityCapturePolicy.Decide(VideoMode.DirectBuffers, env).UseUnityCapture, Is.False); + } + + [Test] + public void The_environment_is_not_read_unless_direct_buffers_is_requested() + { + // Reading it touches SystemInfo, which only works on the main thread: a launch from another thread that does + // not use DirectBuffers must never do it. + int reads = 0; + UnityCaptureEnvironment Probe() { reads++; return Supported(); } + foreach (var options in new[] { null, new Dictionary(), Mode((int)VideoMode.V2), Mode(VideoMode.Fullscreen) }) + { + var output = UnityCapturePolicy.Apply(options, Probe, out var decision); + Assert.That(output, Is.SameAs(options)); + Assert.That(decision.UseUnityCapture, Is.False); + } + Assert.That(reads, Is.EqualTo(0)); + UnityCapturePolicy.Apply(Mode((int)VideoMode.DirectBuffers), Probe, out _); + Assert.That(reads, Is.EqualTo(1)); + } + [Test] public void Missing_screen_capture_module_falls_back() { diff --git a/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj b/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj index 0b9f9c9..ef9c0c0 100644 --- a/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj +++ b/Tools~/CompileCheck/Runtime/Bugsee.Runtime.csproj @@ -24,7 +24,16 @@ Community repackaging of Unity's reference assemblies on nuget.org (not an official Unity feed), pinned by exact version and by obj lock file (restore runs in locked mode). --> - + + + + + + diff --git a/Tools~/scripts/compile-check.sh b/Tools~/scripts/compile-check.sh index da98cb3..74570ea 100644 --- a/Tools~/scripts/compile-check.sh +++ b/Tools~/scripts/compile-check.sh @@ -4,6 +4,9 @@ # Unity's assemblies on NuGet, pinned by version + lock files in Tools~/CompileCheck. No Unity license. # Limits: the Editor reference is Unity 2020.2 plus small source stubs (UnityEditorStubs.cs, # UnityEditorXcodeStubs.cs) for newer/missing APIs. Stubs cannot detect API gaps vs Unity 2021.3 itself. +# The android-player-unity-capture config type-checks the Unity 2022.2+ capture code against a local editor's Managed +# directory (UNITY_MANAGED_DIR, default: the installed 6000.0.84f1 editor); it is skipped with a notice when there is none, +# which is always the case on CI. # Usage: sh Tools~/scripts/compile-check.sh (needs the .NET SDK; DOTNET_ROLL_FORWARD=Major is fine) set -u @@ -27,6 +30,26 @@ for p in Runtime Editor; do done fail=0 +unity_managed="${UNITY_MANAGED_DIR:-/Applications/Unity/Hub/Editor/6000.0.84f1/Unity.app/Contents/Managed}" +if [ -d "$unity_managed/UnityEngine" ]; then + defs="UNITY_ANDROID;ENABLE_IL2CPP;BUGSEE_HAS_SCREENCAPTURE;BUGSEE_UNITY_CAPTURE" + out="$(dotnet build "$dir/Runtime/Bugsee.Runtime.csproj" --nologo -v q -p:RestoreLockedMode=false "-p:NuGetLockFilePath=$dir/Runtime/obj-unity-capture/packages.lock.json" \ + -p:BaseIntermediateOutputPath="$dir/Runtime/obj-unity-capture/" -p:OutputPath="$dir/Runtime/bin-unity-capture/" \ + "-p:UnityManagedDir=$unity_managed" "-p:DefineConstants=$(printf '%s' "$defs" | sed 's/;/%3B/g')" 2>&1)" + status=$? + errors="$(printf '%s\n' "$out" | grep -E ': error ' | sed 's/ \[[^]]*\]$//' | sort -u)" + if [ -z "$errors" ] && [ $status -ne 0 ]; then + errors="build failed without a compiler diagnostic: $(printf '%s\n' "$out" | tail -n 5)" + fi + if [ -n "$errors" ]; then + echo "FAIL android-player-unity-capture ($defs)"; printf '%s\n' "$errors" | sed 's/^/ /'; fail=1 + else + echo "ok android-player-unity-capture ($defs)" + fi +else + echo "skip android-player-unity-capture: no Unity 2022.2+ editor Managed directory (set UNITY_MANAGED_DIR)" +fi + for cfg in $configs; do name="${cfg%%|*}"; rest="${cfg#*|}"; player="${rest%%|*}"; defs="${rest#*|}" if [ "$player" = true ]; then proj="$dir/Runtime/Bugsee.Runtime.csproj"; else proj="$dir/Editor/Bugsee.Editor.csproj"; fi From 96cc5c7fd08beb110372a14d99f883d6dd6032d9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:10:22 +0500 Subject: [PATCH 134/157] Delta review: deferred fallback, probe order, overflow-safe masking, Unity surface lookup, release-channel gate. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 2 +- Runtime/Bugsee.cs | 8 +- Runtime/Capture/BugseeFrameCapturer.cs | 146 +++++++++++++++--- Runtime/Internal/UnityCaptureProbe.cs | 7 +- Runtime/WrapperPolicy/CaptureStartup.cs | 69 ++++++++- Runtime/WrapperPolicy/FrameRedaction.cs | 8 +- Runtime/WrapperPolicy/UnityCapturePolicy.cs | 6 +- .../BugseeHarness/BugseeSampleBootstrap.cs | 37 ++++- Tests~/WrapperPolicy/CaptureStartupTests.cs | 91 +++++++++++ Tests~/WrapperPolicy/FramePushPolicyTests.cs | 16 ++ .../WrapperPolicy/UnityCapturePolicyTests.cs | 9 ++ 12 files changed, 356 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b9f4d..4b1f9d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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 and the pusher fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). +- 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. 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index ce36e4c..3b75710 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -236,7 +236,7 @@ Not benchmarked beyond one tablet; do not enable it in production without measur **Unity versions.** Supported: release builds (channel `f` or `p`) of Unity 2022.2.0f1 and newer, Android player builds, with the Screen Capture module. `AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11 (checked against Unity's reference source: 2022.2.0a1, 2022.1 and 2021.3 do not have it; 2022.2.0b1, all of 2022.3 LTS and 6000.x do), but alpha and beta builds are treated as unsupported. **Not supported: Unity 2021.3 and 2022.1.** The only alternatives there (a Java `byte[]` per frame, or a file) were measured and rejected (about 0.9 s per 1 MB frame, or disk writes). The minimum is one constant, `UnityCapturePolicy.MinimumUnityVersion`, plus the `BUGSEE_UNITY_CAPTURE` version define (`2022.2.0`) in `Bugsee.Runtime.asmdef`. -**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: Unity older than 2022.2.0b1, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are measured; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch was accepted (then the wrapper relaunches with the default capture, unless a newer Launch/Relaunch is waiting, which then decides). The capturer follows what the native SDK actually did: an ignored Launch (SDK already running) or a refused one leaves it alone, and an accepted one is read back from the SDK's effective launch options. Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. +**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: Unity that is not a release build of 2022.2.0f1 or newer, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are measured; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch was accepted (then the wrapper relaunches with the default capture, unless a newer Launch/Relaunch is waiting, which then decides). The capturer follows what the native SDK actually did: an ignored Launch (SDK already running) or a refused one leaves it alone, and an accepted one is read back from the SDK's effective launch options. Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. ```csharp // using Bugsee.Capture; diff --git a/Runtime/Bugsee.cs b/Runtime/Bugsee.cs index 2a3b50e..488d24a 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -118,6 +118,9 @@ static Dictionary ApplyCapturePolicy(Dictionary // relaunch would replace its options in the lifecycle queue, so the fallback is left to that request. static int _pendingLaunches; + // Not atomic with the lifecycle machine: a request can arrive between this check and the relaunch. The window is + // small, a request made then is queued behind it (it still runs and decides), and the worst case is that the fallback + // relaunch's options are replaced by that request's, which has its own policy/fallback handling when it is answered. static bool NoNewerLaunchWaiting() => Volatile.Read(ref _pendingLaunches) == 0; static void AfterLaunchResultCounted(bool accepted, bool startPusher, Dictionary options) @@ -321,8 +324,11 @@ public static void AddNetworkEvent(INetworkEvent networkEvent) => /// handed to the native SDK can finish after the 10 s warning, and after the 60 s hard deadline an Android /// Launch can run while a native delete is still wiping. /// - public static void DeleteCollectedDataOnDevice() => + public static void DeleteCollectedDataOnDevice() + { + ForgetPusherSession(); // it stops the SDK Bridge.DeleteCollectedDataOnDevice(); + } public static IReport CreateReport() => Bridge.CreateReport(); diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index 4ba0c4f..d768d82 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -81,8 +81,9 @@ public sealed class BugseeFrameCapturer : MonoBehaviour /// (no AndroidJNI.NewDirectByteBuffer), without the Screen Capture module, or when the device cannot do GPU /// readback. Call after or around Bugsee.Launch with VideoMode.DirectBuffers; frames are skipped until the SDK /// is launched. Called off the main thread it is queued and returns true before it has run: whether it actually - /// started is then visible through (and a failed start makes the wrapper relaunch with the - /// default capture when the launch asked for DirectBuffers). + /// started is then visible through . (A launch that asks for DirectBuffers starts the + /// capturer itself on the main thread and relaunches with the default capture if it cannot start; that path does + /// not depend on this return value.) /// public static bool Enable(BugseeFrameCapturerOptions options = null) { @@ -140,6 +141,13 @@ BugseeFrameCapturerStats ReadStats() #if BUGSEE_READBACK static bool EnableReadback(BugseeFrameCapturerOptions options) { + // The compile-time define also matches alpha and beta builds; only release builds are supported. + if (!UnityCapturePolicy.IsSupportedUnityVersion(Application.unityVersion)) + { + Debug.LogWarning("[BugseeFrameCapturer] Unity " + Application.unityVersion + " is not supported (release builds of " + + UnityCapturePolicy.MinimumUnityVersion + " or newer only); not enabled."); + return false; + } bool vulkan = SystemInfo.graphicsDeviceType == GraphicsDeviceType.Vulkan; if (!FramePushPolicy.IsReadbackUsable(SystemInfo.supportsAsyncGPUReadback, vulkan, Application.unityVersion)) { @@ -165,7 +173,6 @@ static bool EnableReadback(BugseeFrameCapturerOptions options) const string AddFrameSignature = "(JILjava/nio/ByteBuffer;IIIL" + RowOrderClass + ";)V"; const string TimeUtilsClass = "com/bugsee/library/shared/util/TimeUtils"; const float StatsLogIntervalSeconds = 10f; - const double ViewRegionRefreshSeconds = 1.0; const int AndroidContentViewId = 16908290; // android.R.id.content const int ProbeFrames = 3; @@ -180,11 +187,9 @@ static bool EnableReadback(BugseeFrameCapturerOptions options) int _captureW, _captureH; long _requestTimestampMs; - // Where Unity's view sits in the window, in native window pixels (what secure rectangles are registered in): - // refreshed about once a second and when the Unity screen size changes. Width 0 means unknown (fail closed). - int _lastScreenW, _lastScreenH; - SourceRegion _viewRegion, _requestRegion; - double _nextViewRegion; + // Where Unity's surface sits in the window, in native window pixels (what secure rectangles are registered in), read + // at every request and every readback. Width 0 means unknown (fail closed). + SourceRegion _viewRegion, _requestRegion, _loggedRegion; int[] _rects; int[] _requestRects; // rectangles registered when the capture started int _requestRectCount; @@ -405,7 +410,7 @@ void Tick() try { - UpdateViewRegion(now); + _viewRegion = ReadViewRegion(); if (!EnsureBuffers()) { _policy.Complete(false); return; } _requestTimestampMs = NowMs(); _requestTicks = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -423,32 +428,122 @@ void Tick() } // Secure rectangles are native window pixels, and Unity's view need not fill the window (status bar, cutout, - // multi-window, reduced render resolution), so the rectangles are mapped relative to the view's position in the - // window. Re-read about once a second and when the Unity screen size changes; unknown if it cannot be read. - void UpdateViewRegion(double now) + // multi-window, reduced render resolution), so the rectangles are mapped relative to the Unity surface's position in + // the window. Read at every request and at every readback (no time-based cache), with raw JNI (no managed garbage). + // The Unity view is the first SurfaceView under the activity's content view; if there is none, or anything fails, + // the region is unknown and masking fails closed. + SourceRegion ReadViewRegion() { - if (Screen.width == _lastScreenW && Screen.height == _lastScreenH && now < _nextViewRegion && _viewRegion.IsKnown) return; - _lastScreenW = Screen.width; - _lastScreenH = Screen.height; - _nextViewRegion = now + ViewRegionRefreshSeconds; try { - using (var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) - using (var activity = player.GetStatic("currentActivity")) - using (var content = activity.Call("findViewById", AndroidContentViewId)) - using (var view = content.Call("getChildAt", 0)) + if (_jni.PlayerClass == IntPtr.Zero && !_jni.Init()) return default; + IntPtr activity = AndroidJNI.GetStaticObjectField(_jni.PlayerClass, _jni.CurrentActivityField); + if (activity == IntPtr.Zero) return default; + _jni.IntArgs[0].i = AndroidContentViewId; + IntPtr content = AndroidJNI.CallObjectMethod(activity, _jni.FindViewById, _jni.IntArgs); + AndroidJNI.DeleteLocalRef(activity); + if (content == IntPtr.Zero) { ClearPendingException(); return default; } + IntPtr surface = FindSurfaceView(content, 0); + AndroidJNI.DeleteLocalRef(content); + if (surface == IntPtr.Zero) { ClearPendingException(); return default; } + _jni.ArrayArgs[0].l = _jni.LocationArray; + AndroidJNI.CallVoidMethod(surface, _jni.GetLocationInWindow, _jni.ArrayArgs); + int left = AndroidJNI.GetIntArrayElement(_jni.LocationArray, 0); + int top = AndroidJNI.GetIntArrayElement(_jni.LocationArray, 1); + int width = AndroidJNI.CallIntMethod(surface, _jni.GetWidth, _noArgs); + int height = AndroidJNI.CallIntMethod(surface, _jni.GetHeight, _noArgs); + AndroidJNI.DeleteLocalRef(surface); + if (ClearPendingException()) return default; + var region = new SourceRegion(left, top, width, height); + if (_options.LogStats && (region.Left != _loggedRegion.Left || region.Top != _loggedRegion.Top + || region.Width != _loggedRegion.Width || region.Height != _loggedRegion.Height)) { - int[] location = new int[2]; - view.Call("getLocationInWindow", location); - _viewRegion = new SourceRegion(location[0], location[1], view.Call("getWidth"), view.Call("getHeight")); + _loggedRegion = region; + Debug.Log("[BugseeFrameCapturer] view region left=" + left + " top=" + top + " size=" + width + "x" + height); } + return region; } catch (Exception) { - _viewRegion = default; // unknown: masking fails closed + ClearPendingException(); + return default; } } + // Depth-first search for the first SurfaceView; returns a new local reference or zero. Depth-limited. + IntPtr FindSurfaceView(IntPtr view, int depth) + { + if (AndroidJNI.IsInstanceOf(view, _jni.SurfaceViewClass)) return AndroidJNI.NewLocalRef(view); + if (depth > 6 || !AndroidJNI.IsInstanceOf(view, _jni.ViewGroupClass)) return IntPtr.Zero; + int count = AndroidJNI.CallIntMethod(view, _jni.GetChildCount, _noArgs); + for (int i = 0; i < count; i++) + { + _jni.IntArgs[0].i = i; + IntPtr child = AndroidJNI.CallObjectMethod(view, _jni.GetChildAt, _jni.IntArgs); + if (child == IntPtr.Zero) continue; + IntPtr found = FindSurfaceView(child, depth + 1); + AndroidJNI.DeleteLocalRef(child); + if (found != IntPtr.Zero) return found; + } + return IntPtr.Zero; + } + + // Cached JNI handles for ReadViewRegion (main thread only). + sealed class ViewJni + { + public IntPtr PlayerClass, CurrentActivityField, FindViewById, GetLocationInWindow, GetWidth, GetHeight, + GetChildCount, GetChildAt, SurfaceViewClass, ViewGroupClass, LocationArray; + public readonly jvalue[] IntArgs = new jvalue[1]; + public readonly jvalue[] ArrayArgs = new jvalue[1]; + + public bool Init() + { + IntPtr player = FindClassOrClear("com/unity3d/player/UnityPlayer"); + IntPtr activity = FindClassOrClear("android/app/Activity"); + IntPtr view = FindClassOrClear("android/view/View"); + IntPtr group = FindClassOrClear("android/view/ViewGroup"); + IntPtr surface = FindClassOrClear("android/view/SurfaceView"); + if (player == IntPtr.Zero || activity == IntPtr.Zero || view == IntPtr.Zero || group == IntPtr.Zero || surface == IntPtr.Zero) + return false; + CurrentActivityField = AndroidJNI.GetStaticFieldID(player, "currentActivity", "Landroid/app/Activity;"); + FindViewById = AndroidJNI.GetMethodID(activity, "findViewById", "(I)Landroid/view/View;"); + GetLocationInWindow = AndroidJNI.GetMethodID(view, "getLocationInWindow", "([I)V"); + GetWidth = AndroidJNI.GetMethodID(view, "getWidth", "()I"); + GetHeight = AndroidJNI.GetMethodID(view, "getHeight", "()I"); + GetChildCount = AndroidJNI.GetMethodID(group, "getChildCount", "()I"); + GetChildAt = AndroidJNI.GetMethodID(group, "getChildAt", "(I)Landroid/view/View;"); + IntPtr array = AndroidJNI.ToIntArray(new int[2]); + if (CurrentActivityField == IntPtr.Zero || FindViewById == IntPtr.Zero || GetLocationInWindow == IntPtr.Zero + || GetWidth == IntPtr.Zero || GetHeight == IntPtr.Zero || GetChildCount == IntPtr.Zero + || GetChildAt == IntPtr.Zero || array == IntPtr.Zero) + { + AndroidJNI.ExceptionClear(); + return false; + } + LocationArray = AndroidJNI.NewGlobalRef(array); + AndroidJNI.DeleteLocalRef(array); + PlayerClass = AndroidJNI.NewGlobalRef(player); + SurfaceViewClass = AndroidJNI.NewGlobalRef(surface); + ViewGroupClass = AndroidJNI.NewGlobalRef(group); + AndroidJNI.DeleteLocalRef(player); + AndroidJNI.DeleteLocalRef(activity); + AndroidJNI.DeleteLocalRef(view); + AndroidJNI.DeleteLocalRef(group); + AndroidJNI.DeleteLocalRef(surface); + return true; + } + + public void Release() + { + IntPtr[] refs = { PlayerClass, SurfaceViewClass, ViewGroupClass, LocationArray }; + for (int i = 0; i < refs.Length; i++) + if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); + PlayerClass = SurfaceViewClass = ViewGroupClass = LocationArray = IntPtr.Zero; + } + } + + readonly ViewJni _jni = new ViewJni(); + // Once per consumer: warn if the SDK is not launched with DirectBuffers (it then ignores pushed frames). void CheckLaunchMode() { @@ -523,7 +618,7 @@ void OnReadback(AsyncGPUReadbackRequest request) // the capture started as well as what is secure now, each mapped with the view position of its own moment. If // the row order is unverified the mask could land on mirrored rows and leak the real area, and if a view // position is unknown the rectangles cannot be placed: both blank the whole frame. - UpdateViewRegion(Time.realtimeSinceStartupAsDouble); + _viewRegion = ReadViewRegion(); int count = SecureRectRegistry.Instance.CopyRects(0, ref _rects); if (count > 0 || _requestRectCount > 0) { @@ -643,6 +738,7 @@ void ReleaseNative() // Both the main thread and the push thread may get here when the capturer is destroyed: only the first releases. if (Interlocked.Exchange(ref _nativeReleased, 1) == 1) return; ReleaseNativeBuffer(); + _jni.Release(); IntPtr[] refs = { _consumer, _topDown, _bottomUp, _timeClass, _elapsedClass, _bugseeClass }; for (int i = 0; i < refs.Length; i++) if (refs[i] != IntPtr.Zero) AndroidJNI.DeleteGlobalRef(refs[i]); diff --git a/Runtime/Internal/UnityCaptureProbe.cs b/Runtime/Internal/UnityCaptureProbe.cs index e96edd8..7ea2a34 100644 --- a/Runtime/Internal/UnityCaptureProbe.cs +++ b/Runtime/Internal/UnityCaptureProbe.cs @@ -16,7 +16,10 @@ static class UnityCaptureProbe [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void Bootstrap() { - Current(); + // RuntimeInitializeOnLoadMethod runs on the main thread; MainThreadDispatcher's own bootstrap may not have run + // yet, so do not go through IsMainThread here. + var env = Read(); + lock (Gate) _cached = env; } public static UnityCaptureEnvironment Current() @@ -52,7 +55,7 @@ static UnityCaptureEnvironment Read() static UnityCaptureEnvironment Unavailable() { - return new UnityCaptureEnvironment { IsAndroidPlayer = true, UnityVersion = null }; + return new UnityCaptureEnvironment { IsAndroidPlayer = true, NotRead = true }; } } } diff --git a/Runtime/WrapperPolicy/CaptureStartup.cs b/Runtime/WrapperPolicy/CaptureStartup.cs index adbcaee..bdddad0 100644 --- a/Runtime/WrapperPolicy/CaptureStartup.cs +++ b/Runtime/WrapperPolicy/CaptureStartup.cs @@ -21,6 +21,8 @@ public sealed class PusherLaunchState readonly object _gate = new object(); bool _active; Dictionary _lastOptions; + bool _fallbackPending; + Dictionary _fallbackOptions; public bool Active { get { lock (_gate) return _active; } } public Dictionary LastOptions { get { lock (_gate) return _lastOptions; } } @@ -38,7 +40,28 @@ public void OnAccepted(VideoMode? effectiveMode, bool startRequested, Dictionary public void OnStopped() { - lock (_gate) { _active = false; _lastOptions = null; } + lock (_gate) { _active = false; _lastOptions = null; _fallbackPending = false; _fallbackOptions = null; } + } + + /// The pusher failed while a newer request was waiting: remember that the session still needs the fallback. + public void DeferFallback(Dictionary options) + { + lock (_gate) { _fallbackPending = true; _fallbackOptions = options ?? _fallbackOptions; } + } + + public bool FallbackPending { get { lock (_gate) return _fallbackPending; } } + + /// The deferred fallback's options (null options are possible), clearing it; false when none was deferred. + public bool TakeDeferredFallback(out Dictionary options) + { + lock (_gate) + { + options = _fallbackOptions; + bool was = _fallbackPending; + _fallbackPending = false; + _fallbackOptions = null; + return was; + } } /// The pusher failed: the options for the fallback relaunch, or false when no DirectBuffers session is known. Consumes the state. @@ -94,6 +117,13 @@ public static void OnLaunchResult( state.OnStopped(); disable(); } + else if (state.FallbackPending && isLatestRequest()) + { + // The pusher failed while this request was waiting, and the session it left is still in DirectBuffers. + state.TakeDeferredFallback(out Dictionary deferred); + warn("the Unity-side frame pusher had stopped and the waiting launch request did not change the session; relaunching with the SDK's default capture."); + relaunch(UnityCapturePolicy.WithFallback(deferred ?? options)); + } return; } @@ -107,9 +137,30 @@ public static void OnLaunchResult( warn("could not read the effective video mode: " + ex.Message); mode = null; } + bool direct = mode.HasValue ? mode.Value == VideoMode.DirectBuffers : startRequested; + + if (state.FallbackPending) + { + // The pusher failed while this request was waiting. If the session it produced is not DirectBuffers the + // failure is moot; if it is, the pusher is not restarted and the session goes to the default capture. + if (!direct) + { + state.TakeDeferredFallback(out _); + state.OnAccepted(mode, startRequested, options); + disable(); + return; + } + if (isLatestRequest()) + { + state.TakeDeferredFallback(out Dictionary deferred); + warn("the Unity-side frame pusher had stopped; relaunching with the SDK's default capture."); + relaunch(UnityCapturePolicy.WithFallback(options ?? deferred)); + } + return; + } + state.OnAccepted(mode, startRequested, options); - bool wantPusher = mode.HasValue ? mode.Value == VideoMode.DirectBuffers : startRequested; - if (!wantPusher) + if (!direct) { disable(); return; @@ -126,14 +177,16 @@ public static void OnLaunchResult( started = false; } if (started) return; - if (!state.TakeFailure(out Dictionary fallbackOptions)) return; + state.TakeFailure(out Dictionary fallbackOptions); + fallbackOptions = fallbackOptions ?? options; if (!isLatestRequest()) { - warn("the Unity-side frame pusher could not start, but a newer launch request is waiting; it decides the capture mode."); + state.DeferFallback(fallbackOptions); + warn("the Unity-side frame pusher could not start, but a newer launch request is waiting; the fallback to the default capture runs when its answer arrives."); return; } warn("the Unity-side frame pusher could not start; relaunching with the SDK's default capture."); - relaunch(UnityCapturePolicy.WithFallback(fallbackOptions ?? options)); + relaunch(UnityCapturePolicy.WithFallback(fallbackOptions)); }); } @@ -147,7 +200,9 @@ public static void OnPusherFailed( if (!state.TakeFailure(out Dictionary options)) return; if (!isLatestRequest()) { - warn("the Unity-side frame pusher stopped, but a newer launch request is waiting; it decides the capture mode."); + // Never dropped: the waiting request's answer decides (see OnLaunchResult). + state.DeferFallback(options); + warn("the Unity-side frame pusher stopped, but a newer launch request is waiting; the fallback to the default capture runs when its answer arrives."); return; } warn("the Unity-side frame pusher stopped after repeated failures; relaunching with the SDK's default capture."); diff --git a/Runtime/WrapperPolicy/FrameRedaction.cs b/Runtime/WrapperPolicy/FrameRedaction.cs index ce34c51..ec24af7 100644 --- a/Runtime/WrapperPolicy/FrameRedaction.cs +++ b/Runtime/WrapperPolicy/FrameRedaction.cs @@ -50,10 +50,10 @@ public static int BlankRects( for (int i = 0; i < count; i++) { int o = i * 4; - int x0 = Clamp(FloorDiv((long)(rects[o] - region.Left) * width, sourceWidth), 0, width); - int y0 = Clamp(FloorDiv((long)(rects[o + 1] - region.Top) * height, sourceHeight), 0, height); - int x1 = Clamp(CeilDiv((long)(rects[o + 2] - region.Left) * width, sourceWidth), 0, width); - int y1 = Clamp(CeilDiv((long)(rects[o + 3] - region.Top) * height, sourceHeight), 0, height); + int x0 = Clamp(FloorDiv(((long)rects[o] - region.Left) * width, sourceWidth), 0, width); + int y0 = Clamp(FloorDiv(((long)rects[o + 1] - region.Top) * height, sourceHeight), 0, height); + int x1 = Clamp(CeilDiv(((long)rects[o + 2] - region.Left) * width, sourceWidth), 0, width); + int y1 = Clamp(CeilDiv(((long)rects[o + 3] - region.Top) * height, sourceHeight), 0, height); if (x1 <= x0 || y1 <= y0) continue; for (int y = y0; y < y1; y++) diff --git a/Runtime/WrapperPolicy/UnityCapturePolicy.cs b/Runtime/WrapperPolicy/UnityCapturePolicy.cs index fdf46d2..fe2b23e 100644 --- a/Runtime/WrapperPolicy/UnityCapturePolicy.cs +++ b/Runtime/WrapperPolicy/UnityCapturePolicy.cs @@ -14,6 +14,8 @@ public sealed class UnityCaptureEnvironment public string GraphicsApi; /// The host set BugseeFrameCapturerOptions.RowOrder, which declares the row order of an unmeasured API verified. public bool RowOrderDeclared; + /// The player was not probed yet (a launch from another thread before startup finished): nothing is known. + public bool NotRead; } public sealed class VideoModeDecision @@ -57,7 +59,9 @@ public static VideoModeDecision Decide(VideoMode requested, UnityCaptureEnvironm return new VideoModeDecision { Effective = requested }; string reason = null; - if (!IsSupportedUnityVersion(env.UnityVersion)) + if (env.NotRead) + reason = "the player was not probed yet (the launch came from another thread before Unity finished starting)"; + else if (!IsSupportedUnityVersion(env.UnityVersion)) reason = "Unity " + env.UnityVersion + " has no AndroidJNI.NewDirectByteBuffer (needs " + MinimumUnityVersion + " or newer)"; else if (!env.ScreenCaptureModule) reason = "the Screen Capture module (com.unity.modules.screencapture) is not in the project"; diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index c8f6e38..ba75c59 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -141,7 +141,26 @@ public void LaunchSdk() }; if (!string.IsNullOrWhiteSpace(endpointOverride)) options.Endpoint = endpointOverride.Trim(); - Bugsee.Launch(appToken, options); + // "bugsee_launch_thread" = 1 launches from a worker thread (checks that a launch from any thread works). + if (ReadE2EValue("bugsee_launch_thread", "BUGSEE_LAUNCH_THREAD") == "1") + { + var launchOptions = options; + var token = appToken; + System.Threading.Tasks.Task.Run(() => + { + try { Bugsee.Launch(token, launchOptions); Debug.Log("[BugseeSample] off-thread Launch returned"); } + catch (Exception ex) { Debug.LogError("[BugseeSample] off-thread Launch threw " + ex.GetType().Name + ": " + ex.Message); } + }); + } + else + Bugsee.Launch(appToken, options); + // "bugsee_status_bar" = 1 shows the status bar, so Unity's view no longer fills the window. + if (ReadE2EValue("bugsee_status_bar", "BUGSEE_STATUS_BAR") == "1") + Screen.fullScreen = false; + // "bugsee_orient_after_s" = "12" rotates the activity to landscape after that many seconds (no sensor needed). + var orientAfter = ReadE2EValue("bugsee_orient_after_s", "BUGSEE_ORIENT_AFTER_S"); + if (!string.IsNullOrEmpty(orientAfter)) + StartCoroutine(RotateAfter(orientAfter)); var reportAfter = ReadE2EValue("bugsee_report_after_s", "BUGSEE_REPORT_AFTER_S"); if (!string.IsNullOrEmpty(reportAfter)) StartCoroutine(ReportAfter(reportAfter)); @@ -405,6 +424,14 @@ IEnumerator ReportAfter(string secondsList) } } + IEnumerator RotateAfter(string seconds) + { + if (!float.TryParse(seconds, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var at)) yield break; + yield return new WaitForSeconds(at); + Screen.orientation = ScreenOrientation.LandscapeLeft; + Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2") + " rotated to landscape"); + } + IEnumerator SecureRectDemo(string fractionsList, string removeAfterSeconds, string delaySeconds) { var inv = System.Globalization.CultureInfo.InvariantCulture; @@ -413,10 +440,14 @@ IEnumerator SecureRectDemo(string fractionsList, string removeAfterSeconds, stri var added = new List(); foreach (var fractions in fractionsList.Split(';')) { - var p = fractions.Split(','); + // "px:l,t,r,b" is absolute window pixels, else fractions of the Unity screen. + bool absolute = fractions.StartsWith("px:"); + var p = (absolute ? fractions.Substring(3) : fractions).Split(','); if (p.Length != 4) continue; float l = float.Parse(p[0], inv), t = float.Parse(p[1], inv), r = float.Parse(p[2], inv), b = float.Parse(p[3], inv); - var rect = new RectInt((int)(l * Screen.width), (int)(t * Screen.height), (int)((r - l) * Screen.width), (int)((b - t) * Screen.height)); + var rect = absolute + ? new RectInt((int)l, (int)t, (int)(r - l), (int)(b - t)) + : new RectInt((int)(l * Screen.width), (int)(t * Screen.height), (int)((r - l) * Screen.width), (int)((b - t) * Screen.height)); Bugsee.AddSecureRectangle(rect); added.Add(rect); Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2", inv) + " secure rect added " + rect + " screen=" + Screen.width + "x" + Screen.height); diff --git a/Tests~/WrapperPolicy/CaptureStartupTests.cs b/Tests~/WrapperPolicy/CaptureStartupTests.cs index 2581ccd..e0b7a54 100644 --- a/Tests~/WrapperPolicy/CaptureStartupTests.cs +++ b/Tests~/WrapperPolicy/CaptureStartupTests.cs @@ -238,5 +238,96 @@ public void An_exception_while_reading_the_effective_mode_is_survived() Assert.That(enabled, Is.True, "falls back to what the request asked for"); Assert.That(warnings, Has.Count.EqualTo(1)); } + + // The pusher fails while a user request is waiting: the fallback is deferred, never dropped, and decided by the + // request's answer. + Fake FailedWhileWaiting() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + f.Latest = false; + CaptureStartup.OnPusherFailed(f.State, () => f.Latest, o => f.Relaunches.Add(new Dictionary(o)), f.Warnings.Add); + Assert.That(f.Relaunches, Is.Empty); + Assert.That(f.State.FallbackPending, Is.True); + return f; + } + + [Test] + public void Pusher_failed_during_a_waiting_launch_the_sdk_ignored_still_gets_the_fallback() + { + var f = FailedWhileWaiting(); + f.Latest = true; + f.Result(false, false, Mode(VideoMode.V2)); // ignored: SDK already running + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(f.Relaunches[0]["other"], Is.EqualTo(1), "on top of the options of the session that was running"); + Assert.That(f.State.FallbackPending, Is.False); + } + + [Test] + public void Pusher_failed_during_a_waiting_relaunch_that_was_refused_still_gets_the_fallback() + { + var f = FailedWhileWaiting(); + f.Latest = true; + f.Result(false, true, Mode(VideoMode.DirectBuffers)); // refused + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + } + + [Test] + public void Pusher_failed_during_a_waiting_launch_that_was_accepted_with_the_default_mode_drops_the_fallback() + { + var f = FailedWhileWaiting(); + f.Latest = true; + f.NativeMode = VideoMode.V2; + f.Result(true, false, Mode(VideoMode.V2)); + Assert.That(f.Relaunches, Is.Empty); + Assert.That(f.State.FallbackPending, Is.False); + Assert.That(f.Enables, Is.EqualTo(1), "only the original start: the pusher is not restarted"); + } + + [Test] + public void Pusher_failed_during_a_waiting_launch_accepted_with_direct_buffers_goes_to_the_default_capture_with_the_new_options() + { + var f = FailedWhileWaiting(); + f.Latest = true; + f.Result(true, true, new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.DirectBuffers }, { "other", 2 } }); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0][Options.CaptureVideoMode], Is.EqualTo((int)VideoMode.V2)); + Assert.That(f.Relaunches[0]["other"], Is.EqualTo(2)); + Assert.That(f.Enables, Is.EqualTo(1)); + } + + [Test] + public void A_deferred_fallback_waits_while_yet_another_request_is_still_pending() + { + var f = FailedWhileWaiting(); + f.Latest = false; // a second request is queued behind the first + f.Result(false, false, Mode(VideoMode.V2)); + Assert.That(f.Relaunches, Is.Empty); + Assert.That(f.State.FallbackPending, Is.True); + f.Latest = true; + f.Result(false, false, Mode(VideoMode.V2)); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + } + + [Test] + public void A_start_failure_with_a_newer_request_waiting_is_deferred_not_dropped() + { + var f = new Fake { Running = true, NativeMode = VideoMode.DirectBuffers, EnableResult = false, Latest = false }; + f.Result(true, true, Mode(VideoMode.DirectBuffers)); + Assert.That(f.State.FallbackPending, Is.True); + f.Latest = true; + f.Result(false, false, Mode(VideoMode.V2)); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + } + + [Test] + public void Stopping_the_session_cancels_a_deferred_fallback() + { + var f = FailedWhileWaiting(); + f.State.OnStopped(); + Assert.That(f.State.FallbackPending, Is.False); + } } } diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index 71a3eb6..dbadd3c 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -319,6 +319,22 @@ public void View_offset_shifts_the_rect_before_scaling() Assert.That(Blank(b, 3, row, 10), Is.EqualTo(row < 4), "row " + row); } + [Test] + public void Extreme_rectangle_values_do_not_overflow_into_the_frame_or_out_of_it() + { + var b = White(); + // int.MinValue - Left would wrap if subtracted as ints; as longs it is far outside and clamps to the left edge. + Assert.That(FrameRedaction.BlankRects(b, W, H, VideoFrameRowOrder.TopDown, new[] { int.MinValue, int.MinValue, 2, 2 }, 1, new SourceRegion(100, 100, 4, 4)), Is.EqualTo(0)); + var c = White(); + FrameRedaction.BlankRects(c, W, H, VideoFrameRowOrder.TopDown, new[] { int.MinValue, int.MinValue, int.MaxValue, int.MaxValue }, 1, new SourceRegion(int.MaxValue, int.MaxValue, 4, 4)); + var d = White(); + FrameRedaction.BlankRects(d, W, H, VideoFrameRowOrder.TopDown, new[] { int.MinValue, int.MinValue, int.MaxValue, int.MaxValue }, 1, new SourceRegion(-1000, -1000, 4, 4)); + for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(d, x, y), Is.True); + var e = White(); + FrameRedaction.BlankRects(e, W, H, VideoFrameRowOrder.TopDown, new[] { int.MinValue, 0, 2, 1 }, 1, new SourceRegion(int.MaxValue, 0, 4, 4)); + Assert.That(Untouched(e, 0, 0) || Blank(e, 0, 0), Is.True); + } + [Test] public void Rect_outside_the_view_masks_nothing() { diff --git a/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs b/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs index 727275d..62dc892 100644 --- a/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs +++ b/Tests~/WrapperPolicy/UnityCapturePolicyTests.cs @@ -84,6 +84,15 @@ public void The_environment_is_not_read_unless_direct_buffers_is_requested() Assert.That(reads, Is.EqualTo(1)); } + [Test] + public void An_unread_player_falls_back_with_an_honest_reason() + { + var d = UnityCapturePolicy.Decide(VideoMode.DirectBuffers, new UnityCaptureEnvironment { IsAndroidPlayer = true, NotRead = true }); + Assert.That(d.Effective, Is.EqualTo(VideoMode.V2)); + Assert.That(d.FallbackReason, Does.Contain("not probed")); + Assert.That(d.FallbackReason, Does.Not.Contain("NewDirectByteBuffer")); + } + [Test] public void Missing_screen_capture_module_falls_back() { From 7e7e500249d076b08c35437d1335a334ff72746d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:17:45 +0500 Subject: [PATCH 135/157] Use Unity's own Gradle templates so Android builds work on Unity 6. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package wrote hand-made baseProjectTemplate/launcherTemplate pinning AGP 7.4.2, which cannot run on Unity 6's Gradle 9.3.1 (DependencyHandler.module removed). Copy the templates the installed Unity ships instead and only patch in the Bugsee plugin lines (Unity 6+) and 2021.3 release debugSymbolLevel. Warn when a project template pins another AGP major. Drop the sample's custom templates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + Editor/BugseeAndroidGradleSetup.cs | 172 +++++++----------- .../WrapperPolicy/GradleTemplateDefaults.cs | 88 +++++++++ .../GradleTemplateDefaults.cs.meta | 11 ++ Samples~/BugseeMiniGame/Assets/Plugins.meta | 8 - .../Assets/Plugins/Android.meta | 8 - .../Android/baseProjectTemplate.gradle.meta | 7 - .../Plugins/Android/gradleTemplate.properties | 5 - .../Android/gradleTemplate.properties.meta | 7 - .../Android/launcherTemplate.gradle.meta | 7 - Samples~/BugseeMiniGame/README.md | 2 +- .../Bugsee.WrapperPolicy.Tests.csproj | 1 + .../2021.3.45f1}/baseProjectTemplate.gradle | 1 - .../2021.3.45f1}/launcherTemplate.gradle | 6 +- .../6000.0.84f1/baseProjectTemplate.gradle | 13 ++ .../6000.0.84f1/launcherTemplate.gradle | 70 +++++++ .../GradleTemplateDefaultsTests.cs | 70 +++++++ .../WrapperPolicy/MarkedGradleBlockTests.cs | 18 +- 18 files changed, 327 insertions(+), 168 deletions(-) create mode 100644 Runtime/WrapperPolicy/GradleTemplateDefaults.cs create mode 100644 Runtime/WrapperPolicy/GradleTemplateDefaults.cs.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties.meta delete mode 100644 Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle.meta rename {Samples~/BugseeMiniGame/Assets/Plugins/Android => Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1}/baseProjectTemplate.gradle (99%) rename {Samples~/BugseeMiniGame/Assets/Plugins/Android => Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1}/launcherTemplate.gradle (95%) create mode 100644 Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/baseProjectTemplate.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/launcherTemplate.gradle create mode 100644 Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68332de..0ffa11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Android builds on Unity 6 failed in Gradle (`DependencyHandler.module(java.lang.Object)` while evaluating `:launcher`): the package wrote its own `Assets/Plugins/Android/baseProjectTemplate.gradle` and `launcherTemplate.gradle` pinning Android Gradle plugin 7.4.2, which cannot run on Unity 6's Gradle 9. When a project has no such templates the package now copies the ones the installed Unity ships (AGP 7.4.2 on 2021.3, 9.0.0 on 6000.0.84f1) and only adds the Bugsee Gradle plugin lines (Unity 6+) and, on 2021.3, release `debugSymbolLevel 'FULL'`. A template that pins another AGP major than Unity's default (for example one left by an earlier version of this package) logs a warning. The sample no longer ships custom Gradle templates. Verified with real IL2CPP arm64 development builds on 2021.3.45f1 and 6000.0.84f1; Bugsee Gradle plugin 4.0.8 works with Unity 6's AGP 9.0.0 / Gradle 9.3.1. - Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. - iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 52a933e..7757855 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Text; using Bugsee.WrapperPolicy; using UnityEditor; using UnityEditor.Build; @@ -162,135 +161,90 @@ static int GetUnityMajorVersion() static void WriteBaseProjectTemplate(bool applyPlugin) { string path = Path.Combine(Application.dataPath, "Plugins", "Android", "baseProjectTemplate.gradle"); - if (File.Exists(path)) + string text = ReadOrCopyUnityDefault(path, "baseProjectTemplate.gradle"); + if (text == null) { - if (applyPlugin) - { - TryApplyMarkedGradle( - path, - "plugins {", - $" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false // bugsee:gradle-plugin"); - } - return; } - var sb = new StringBuilder(); - sb.AppendLine("plugins {"); - sb.AppendLine(" // If you are changing the Android Gradle Plugin version, make sure it is compatible with the Gradle version preinstalled with Unity"); - sb.AppendLine(" // See which Gradle version is preinstalled with Unity here https://docs.unity3d.com/Manual/android-gradle-overview.html"); - sb.AppendLine(" // See official Gradle and Android Gradle Plugin compatibility table here https://developer.android.com/studio/releases/gradle-plugin#updating-gradle"); - sb.AppendLine(" // 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"); - sb.AppendLine(" id 'com.android.application' version '7.4.2' apply false"); - sb.AppendLine(" id 'com.android.library' version '7.4.2' apply false"); + WarnIfIncompatibleAgp(path, text, "baseProjectTemplate.gradle"); if (applyPlugin) { - sb.AppendLine( + TryApplyMarkedGradle( + path, + "plugins {", $" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false // bugsee:gradle-plugin"); } - - sb.AppendLine(" **BUILD_SCRIPT_DEPS**"); - sb.AppendLine("}"); - sb.AppendLine(); - sb.AppendLine("task clean(type: Delete) {"); - sb.AppendLine(" delete rootProject.buildDir"); - sb.AppendLine("}"); - sb.AppendLine(); - WriteIfChanged(path, sb.ToString()); } static void WriteLauncherTemplate(bool applyPlugin) { string path = Path.Combine(Application.dataPath, "Plugins", "Android", "launcherTemplate.gradle"); - if (File.Exists(path)) + bool existed = File.Exists(path); + string text = ReadOrCopyUnityDefault(path, "launcherTemplate.gradle"); + if (text == null) { - if (applyPlugin) - { - TryApplyMarkedGradle( - path, - "apply plugin: 'com.android.application'", - LauncherMarkedGradlePatch()); - } - return; } - var sb = new StringBuilder(); - sb.AppendLine("apply plugin: 'com.android.application'"); + if (!existed) + { + // Only for the copy made here; an existing template is the project's own. + WriteIfChanged(path, GradleTemplateDefaults.EnsureReleaseDebugSymbols(text)); + } + if (applyPlugin) { - foreach (string line in LauncherMarkedGradlePatch().Split('\n')) - { - sb.AppendLine(line); - } + TryApplyMarkedGradle( + path, + "apply plugin: 'com.android.application'", + LauncherMarkedGradlePatch()); } + } + + static string UnityTemplatePath(string name) + { + // The module is not inside Unity.app on macOS, so ask Unity where Android Build Support lives. + string engine = BuildPipeline.GetPlaybackEngineDirectory(BuildTarget.Android, BuildOptions.None); + return string.IsNullOrEmpty(engine) ? null : Path.Combine(engine, "Tools", "GradleTemplates", name); + } - sb.AppendLine(); - sb.AppendLine("dependencies {"); - sb.AppendLine(" implementation project(':unityLibrary')"); - sb.AppendLine("}"); - sb.AppendLine(); - sb.AppendLine("android {"); - sb.AppendLine(" namespace \"**NAMESPACE**\""); - sb.AppendLine(" ndkPath \"**NDKPATH**\""); - sb.AppendLine(" compileSdkVersion **APIVERSION**"); - sb.AppendLine(" buildToolsVersion '**BUILDTOOLS**'"); - sb.AppendLine(); - sb.AppendLine(" compileOptions {"); - sb.AppendLine(" sourceCompatibility JavaVersion.VERSION_11"); - sb.AppendLine(" targetCompatibility JavaVersion.VERSION_11"); - sb.AppendLine(" }"); - sb.AppendLine(); - sb.AppendLine(" defaultConfig {"); - sb.AppendLine(" minSdkVersion **MINSDKVERSION**"); - sb.AppendLine(" targetSdkVersion **TARGETSDKVERSION**"); - sb.AppendLine(" applicationId '**APPLICATIONID**'"); - sb.AppendLine(" ndk {"); - sb.AppendLine(" abiFilters **ABIFILTERS**"); - sb.AppendLine(" }"); - sb.AppendLine(" versionCode **VERSIONCODE**"); - sb.AppendLine(" versionName '**VERSIONNAME**'"); - sb.AppendLine(" }"); - sb.AppendLine(); - sb.AppendLine(" aaptOptions {"); - sb.AppendLine(" noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ')"); - sb.AppendLine(" ignoreAssetsPattern = \"!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~\""); - sb.AppendLine(" }**SIGN**"); - sb.AppendLine(); - sb.AppendLine(" lintOptions {"); - sb.AppendLine(" abortOnError false"); - sb.AppendLine(" }"); - sb.AppendLine(); - sb.AppendLine(" buildTypes {"); - sb.AppendLine(" debug {"); - sb.AppendLine(" minifyEnabled **MINIFY_DEBUG**"); - sb.AppendLine(" proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG**"); - sb.AppendLine(" jniDebuggable true"); - sb.AppendLine(" }"); - sb.AppendLine(" release {"); - sb.AppendLine(" minifyEnabled **MINIFY_RELEASE**"); - sb.AppendLine(" proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG**"); - sb.AppendLine(" ndk {"); - // FULL for DWARF line programs — required for IL2CPP LineNumberMappings primary apply. - sb.AppendLine(" debugSymbolLevel 'FULL'"); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - sb.AppendLine(" }**PACKAGING_OPTIONS****PLAY_ASSET_PACKS****SPLITS**"); - sb.AppendLine("**BUILT_APK_LOCATION**"); - sb.AppendLine(" bundle {"); - sb.AppendLine(" language {"); - sb.AppendLine(" enableSplit = false"); - sb.AppendLine(" }"); - sb.AppendLine(" density {"); - sb.AppendLine(" enableSplit = false"); - sb.AppendLine(" }"); - sb.AppendLine(" abi {"); - sb.AppendLine(" enableSplit = true"); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - sb.AppendLine("}**SPLITS_VERSION_CODE****LAUNCHER_SOURCE_BUILD_SETUP**"); - sb.AppendLine(); - WriteIfChanged(path, sb.ToString()); + /// + /// The project's template, or a copy of the one this Unity ships (AGP/Gradle versions and the launcher + /// layout differ per Unity line, so the package never writes its own). Null when there is none to copy + /// (no Android Build Support module); the Unity default is then used unmodified. + /// + static string ReadOrCopyUnityDefault(string path, string name) + { + if (File.Exists(path)) + { + return File.ReadAllText(path); + } + + string source = UnityTemplatePath(name); + if (source == null || !File.Exists(source)) + { + Debug.LogWarning("[Bugsee] Unity's default " + name + " was not found (" + source + "); not creating one."); + return null; + } + + string text = File.ReadAllText(source); + WriteIfChanged(path, text); + return text; + } + + static void WarnIfIncompatibleAgp(string path, string projectText, string name) + { + string source = UnityTemplatePath(name); + if (source != null && File.Exists(source) && GradleTemplateDefaults.PinsDifferentAgpMajor(projectText, File.ReadAllText(source))) + { + Debug.LogWarning( + "[Bugsee] " + path + " pins Android Gradle plugin " + GradleTemplateDefaults.PinnedAgpMajor(projectText) + + ".x but Unity " + Application.unityVersion + " builds with " + + GradleTemplateDefaults.PinnedAgpMajor(File.ReadAllText(source)) + + ".x, so the Android build will fail in Gradle. Delete the custom template (Bugsee copies Unity's default) " + + "or update the pinned version."); + } } internal static string LauncherMarkedGradlePatch() diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs new file mode 100644 index 0000000..8f9289b --- /dev/null +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -0,0 +1,88 @@ +using System; +using System.Text.RegularExpressions; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Edits to Unity's own default Gradle templates (copied into Assets/Plugins/Android when a project has none). + /// The package never writes its own copy of a template: the Android Gradle plugin and Gradle versions belong to + /// the Unity line (AGP 7.4.2 on 2021.3, AGP 9.0.0 on Unity 6000.0.84f1) and a pinned copy breaks every other line. + /// + public static class GradleTemplateDefaults + { + static readonly Regex ApplicationPluginVersion = new Regex( + @"id\s+['""]com\.android\.application['""]\s+version\s+['""](?[^'""]+)['""]", + RegexOptions.CultureInvariant); + + static readonly Regex ClasspathVersion = new Regex( + @"com\.android\.tools\.build:gradle:(?[0-9][^'""\s]*)", + RegexOptions.CultureInvariant); + + /// The Android Gradle plugin major a base project template pins, or -1 when it pins none (or not literally). + public static int PinnedAgpMajor(string baseProjectTemplate) + { + if (baseProjectTemplate == null) + { + return -1; + } + + Match m = ApplicationPluginVersion.Match(baseProjectTemplate); + if (!m.Success) + { + m = ClasspathVersion.Match(baseProjectTemplate); + } + + if (!m.Success) + { + return -1; + } + + string v = m.Groups["v"].Value; + int dot = v.IndexOf('.'); + return int.TryParse(dot < 0 ? v : v.Substring(0, dot), out int major) ? major : -1; + } + + /// + /// True when pins another AGP major than Unity's default: Gradle in this + /// Unity cannot run it (AGP 7 calls DependencyHandler.module, removed in Gradle 9). + /// + public static bool PinsDifferentAgpMajor(string projectTemplate, string unityDefaultTemplate) + { + int project = PinnedAgpMajor(projectTemplate); + int unity = PinnedAgpMajor(unityDefaultTemplate); + return project > 0 && unity > 0 && project != unity; + } + + /// + /// Unity 2021.3's default launcher template sets no native debug symbol level, so the release build type + /// gets ndk { debugSymbolLevel 'FULL' } (DWARF line programs, needed for the IL2CPP line mapping). + /// Unity 6's template already carries a debugSymbolLevel, which Unity sets from the Player Settings. + /// A template that already mentions debugSymbolLevel, or has no single release { line, is returned unchanged. + /// + public static string EnsureReleaseDebugSymbols(string launcherTemplate) + { + if (launcherTemplate == null) + { + throw new ArgumentNullException(nameof(launcherTemplate)); + } + + if (launcherTemplate.IndexOf("debugSymbolLevel", StringComparison.Ordinal) >= 0) + { + return launcherTemplate; + } + + MatchCollection releases = Regex.Matches(launcherTemplate, @"^([ \t]*)release[ \t]*\{[ \t]*\r?$", RegexOptions.Multiline); + if (releases.Count != 1) + { + return launcherTemplate; + } + + Match release = releases[0]; + string indent = release.Groups[1].Value + " "; + string nl = launcherTemplate.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; + string block = nl + indent + "ndk {" + nl + indent + " debugSymbolLevel 'FULL'" + nl + indent + "}"; + int insertAt = release.Index + release.Length - (release.Value.EndsWith("\r") ? 1 : 0); + return launcherTemplate.Insert(insertAt, block); + } + } +} diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs.meta b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs.meta new file mode 100644 index 0000000..e45a2ef --- /dev/null +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff13a1f215024ad1ada40825a62ae2cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins.meta b/Samples~/BugseeMiniGame/Assets/Plugins.meta deleted file mode 100644 index ab3a342..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: ce5d0ff0e97a1421dbf921ab7b39d176 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android.meta b/Samples~/BugseeMiniGame/Assets/Plugins/Android.meta deleted file mode 100644 index 70d2965..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: ac38d2c71dd5b44b29f07ea371375442 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle.meta b/Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle.meta deleted file mode 100644 index 763ee3b..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 262f6f68edc2543049d1d3ac44f737b7 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties b/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties deleted file mode 100644 index ba3f66f..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties +++ /dev/null @@ -1,5 +0,0 @@ -org.gradle.jvmargs=-Xmx**JVM_HEAP_SIZE**M -org.gradle.parallel=true -unityStreamingAssets=**STREAMING_ASSETS** -android.suppressUnsupportedCompileSdk=35 -**ADDITIONAL_PROPERTIES** diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties.meta b/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties.meta deleted file mode 100644 index 70dbd39..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/gradleTemplate.properties.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ade67d81bf309424fae9dce97a60bee6 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle.meta b/Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle.meta deleted file mode 100644 index 53dbae6..0000000 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: c95c87233f151498287b5dea4ab790dc -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/BugseeMiniGame/README.md b/Samples~/BugseeMiniGame/README.md index e3e2db5..d94ca75 100644 --- a/Samples~/BugseeMiniGame/README.md +++ b/Samples~/BugseeMiniGame/README.md @@ -68,7 +68,7 @@ SetSecureRect / SetAttribute / SetUserIdentifier / Appearance open IMGUI forms f 3. Build & Run on device. Each build declares `bugsee-android:7.4.1` + `bugsee-android-ndk:7.4.1` in the generated -`unityLibrary/build.gradle` (core publishes no transitives; NDK is explicit); no custom Gradle templates are needed. +`unityLibrary/build.gradle` (core publishes no transitives; NDK is explicit); no custom Gradle templates are needed (the sample ships none; the package copies Unity's own default templates, so it builds on Unity 2021.3 and Unity 6). Gradle plugin `4.0.8` is applied on Unity 6+ launcher templates (`ndk { enabled = true }`). ## Character diff --git a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj index 08cf8b6..86c0d9c 100644 --- a/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj +++ b/Tests~/WrapperPolicy/Bugsee.WrapperPolicy.Tests.csproj @@ -24,5 +24,6 @@ + diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1/baseProjectTemplate.gradle similarity index 99% rename from Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle rename to Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1/baseProjectTemplate.gradle index 8a11235..fd43460 100644 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/baseProjectTemplate.gradle +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1/baseProjectTemplate.gradle @@ -11,4 +11,3 @@ plugins { task clean(type: Delete) { delete rootProject.buildDir } - diff --git a/Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1/launcherTemplate.gradle similarity index 95% rename from Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle rename to Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1/launcherTemplate.gradle index a6e57eb..a105c91 100644 --- a/Samples~/BugseeMiniGame/Assets/Plugins/Android/launcherTemplate.gradle +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2021.3.45f1/launcherTemplate.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.application' dependencies { implementation project(':unityLibrary') -} + } android { namespace "**NAMESPACE**" @@ -44,9 +44,6 @@ android { release { minifyEnabled **MINIFY_RELEASE** proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG** - ndk { - debugSymbolLevel 'FULL' - } } }**PACKAGING_OPTIONS****PLAY_ASSET_PACKS****SPLITS** **BUILT_APK_LOCATION** @@ -62,4 +59,3 @@ android { } } }**SPLITS_VERSION_CODE****LAUNCHER_SOURCE_BUILD_SETUP** - diff --git a/Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/baseProjectTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/baseProjectTemplate.gradle new file mode 100644 index 0000000..941aeb4 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/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 '9.0.0' apply false + id 'com.android.library' version '9.0.0' apply false + **BUILD_SCRIPT_DEPS** +} + +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory +} diff --git a/Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/launcherTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/launcherTemplate.gradle new file mode 100644 index 0000000..a4ec3cf --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/6000.0.84f1/launcherTemplate.gradle @@ -0,0 +1,70 @@ +apply plugin: 'com.android.application' +apply from: 'setupSymbols.gradle' +apply from: '../shared/keepUnitySymbols.gradle' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "**NAMESPACE**" + ndkPath "**NDKPATH**" + ndkVersion "**NDKVERSION**" + compileSdk **APIVERSION** + buildToolsVersion "**BUILDTOOLS**" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + defaultConfig { + minSdk **MINSDK** + targetSdk **TARGETSDK** + applicationId '**APPLICATIONID**' + ndk { + abiFilters **ABIFILTERS** + debugSymbolLevel **DEBUGSYMBOLLEVEL** + } + versionCode **VERSIONCODE** + versionName '**VERSIONNAME**' + } + + androidResources { + noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + }**SIGN** + + lint { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled **MINIFY_DEBUG** + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')**SIGNCONFIG** + jniDebuggable true + } + release { + minifyEnabled **MINIFY_RELEASE** + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')**SIGNCONFIG** + } + }**PACKAGING****PLAY_ASSET_PACKS****SPLITS** +**BUILT_APK_LOCATION** + bundle { + language { + enableSplit false + } + density { + enableSplit false + } + abi { + enableSplit true + } + texture { + enableSplit true + } + } + + **GOOGLE_PLAY_DEPENDENCIES** +}**SPLITS_VERSION_CODE****LAUNCHER_SOURCE_BUILD_SETUP** diff --git a/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs b/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs new file mode 100644 index 0000000..84965ae --- /dev/null +++ b/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs @@ -0,0 +1,70 @@ +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The templates are the ones Unity ships (copied from Hub/Editor/<version>/PlaybackEngines/AndroidPlayer/Tools/GradleTemplates). + /// A hand-written copy that pinned AGP 7.4.2 broke Unity 6000.0.84f1: its Gradle 9.3.1 has no DependencyHandler.module(). + /// + public class GradleTemplateDefaultsTests + { + static string Fixture(string unity, string name) => + File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "UnityTemplates", unity, name)); + + [TestCase("2021.3.45f1", 7)] + [TestCase("6000.0.84f1", 9)] + public void Unity_default_base_template_pins_its_own_agp_major(string unity, int major) + { + Assert.That(GradleTemplateDefaults.PinnedAgpMajor(Fixture(unity, "baseProjectTemplate.gradle")), Is.EqualTo(major)); + } + + [Test] + public void A_template_pinning_another_agp_major_than_unitys_default_is_reported() + { + string unity6 = Fixture("6000.0.84f1", "baseProjectTemplate.gradle"); + string unity2021 = Fixture("2021.3.45f1", "baseProjectTemplate.gradle"); + + Assert.That(GradleTemplateDefaults.PinsDifferentAgpMajor(unity2021, unity6), Is.True); + Assert.That(GradleTemplateDefaults.PinsDifferentAgpMajor(unity6, unity6), Is.False); + Assert.That(GradleTemplateDefaults.PinsDifferentAgpMajor(unity2021, unity2021), Is.False); + Assert.That(GradleTemplateDefaults.PinsDifferentAgpMajor("plugins {\n}\n", unity6), Is.False); + } + + [Test] + public void Classpath_style_pin_is_read() + { + Assert.That(GradleTemplateDefaults.PinnedAgpMajor("classpath 'com.android.tools.build:gradle:8.3.0'"), Is.EqualTo(8)); + Assert.That(GradleTemplateDefaults.PinnedAgpMajor(null), Is.EqualTo(-1)); + } + + [Test] + public void Release_debug_symbols_are_added_to_the_2021_launcher_once() + { + string template = Fixture("2021.3.45f1", "launcherTemplate.gradle"); + string once = GradleTemplateDefaults.EnsureReleaseDebugSymbols(template); + + Assert.That(once, Does.Contain(" release {\n ndk {\n debugSymbolLevel 'FULL'\n }\n minifyEnabled")); + Assert.That(GradleTemplateDefaults.EnsureReleaseDebugSymbols(once), Is.EqualTo(once)); + Assert.That(once.Split('{').Length, Is.EqualTo(once.Split('}').Length)); + } + + [Test] + public void Unity_6_launcher_already_sets_a_symbol_level_and_is_left_alone() + { + string template = Fixture("6000.0.84f1", "launcherTemplate.gradle"); + Assert.That(GradleTemplateDefaults.EnsureReleaseDebugSymbols(template), Is.EqualTo(template)); + } + + [Test] + public void Package_editor_code_does_not_pin_an_agp_version_or_write_a_template_by_hand() + { + string editor = File.ReadAllText(Path.GetFullPath(Path.Combine( + TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "..", "Editor", "BugseeAndroidGradleSetup.cs"))); + + Assert.That(editor, Does.Not.Contain("com.android.application' version")); + Assert.That(editor, Does.Not.Contain("StringBuilder")); + Assert.That(editor, Does.Contain("GradleTemplates")); + } + } +} diff --git a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs index ea463f8..f5e65a9 100644 --- a/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs +++ b/Tests~/WrapperPolicy/MarkedGradleBlockTests.cs @@ -10,11 +10,9 @@ public class MarkedGradleBlockTests const string Marked = " id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin"; const string LauncherAnchor = "apply plugin: 'com.android.application'"; - static string SampleLauncherTemplatePath => - Path.GetFullPath(Path.Combine( - TestContext.CurrentContext.TestDirectory, - "..", "..", "..", "..", "..", - "Samples~", "BugseeMiniGame", "Assets", "Plugins", "Android", "launcherTemplate.gradle")); + static string UnityLauncherTemplate(string unity) => + File.ReadAllText(Path.Combine( + TestContext.CurrentContext.TestDirectory, "Fixtures", "UnityTemplates", unity, "launcherTemplate.gradle")); static string LauncherPatch => "apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin\n\n" + @@ -108,12 +106,12 @@ public void Disabled_ndk_in_marked_block_is_upserted_to_enabled() Assert.That(once, Does.Not.Contain("enabled = false")); } - [Test] - public void Sample_launcher_gets_plugin_and_ndk_block_idempotently() + [TestCase("2021.3.45f1")] + [TestCase("6000.0.84f1")] + public void Unity_default_launcher_gets_plugin_and_ndk_block_idempotently(string unity) { - Assume.That(File.Exists(SampleLauncherTemplatePath), Is.True, "sample launcher template missing"); - string sample = File.ReadAllText(SampleLauncherTemplatePath); - string once = MarkedGradleBlock.Apply(sample, LauncherAnchor, LauncherPatch); + string template = UnityLauncherTemplate(unity); + string once = MarkedGradleBlock.Apply(template, LauncherAnchor, LauncherPatch); Assert.That(once, Does.Contain(MarkedGradleBlock.NdkMarkerComment)); Assert.That(once, Does.Contain("enabled = true")); From 93a73fa2bb27f8d7bdc28f1f85b806e400e10e4d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:18:42 +0500 Subject: [PATCH 136/157] Sample: harness switches for status bar, rotation, off-thread launch, view-position logging. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index ba75c59..7092bba 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -144,12 +144,13 @@ public void LaunchSdk() // "bugsee_launch_thread" = 1 launches from a worker thread (checks that a launch from any thread works). if (ReadE2EValue("bugsee_launch_thread", "BUGSEE_LAUNCH_THREAD") == "1") { + var touched = Bugsee.IsLaunched; // first use of Bugsee initialises its statics, which must happen on the main thread var launchOptions = options; var token = appToken; System.Threading.Tasks.Task.Run(() => { try { Bugsee.Launch(token, launchOptions); Debug.Log("[BugseeSample] off-thread Launch returned"); } - catch (Exception ex) { Debug.LogError("[BugseeSample] off-thread Launch threw " + ex.GetType().Name + ": " + ex.Message); } + catch (Exception ex) { Debug.LogError("[BugseeSample] off-thread Launch threw " + ex); } }); } else From b1c8da4f34b5fee8c26620b5905f8e4cfb6f056d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:38:22 +0500 Subject: [PATCH 137/157] Fix Android Launch from another thread; docs: tablet verification of the review fixes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AndroidBridge.Launch read Application.buildGUID/version (main thread only) on the calling thread; the identity, wrapper and listener setup now run on the main thread. Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 4 ++-- Runtime/Platform/Android/AndroidBridge.cs | 13 +++++++++---- .../Scripts/BugseeHarness/BugseeSampleBootstrap.cs | 8 +++++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Documentation~/index.md b/Documentation~/index.md index 3b75710..db792f9 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -256,5 +256,5 @@ What to know before using it: - Row order of the readback was measured for Vulkan (Galaxy Tab S5e, Android 11, Adreno 615, Unity 6000.0.84f1: TopDown) and for OpenGLES3 (Android 16 arm64 emulator, swiftshader, Unity 6000.0.84f1: BottomUp, with a red-over-blue pattern; no GLES3 device was measured). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (not reachable on supported versions). After 5 failed readbacks in a row it turns itself off. - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. -- Measured once, on a Galaxy Tab S5e (Android 11, Vulkan, Unity 6000.0.84f1 IL2CPP development build, the sample scene, 400x640 capture at `TargetFps` 5, about 100 s per run, pusher on versus the SDK's default capture): about 4.6 frames/s pushed, 0 failures, 2-3 frames dropped in flight; readback latency 81-90 ms; `addFrame` 18-22 ms on the push thread. Unity frame rate 38.3 versus 38.6 fps, frame time p50 33.1 ms and p95 33.1 ms in both. The default capture's `BugseeScreenCap` thread (about 6-13% of a core) is replaced by the push thread (about 3-7%); main thread about 47-57% of a core either way; total PSS 383-391 MB versus 389-400 MB. One device and one light scene, not a benchmark of heavier games. -- Decoded report videos (640x640 h264) with a red-top/blue-bottom pattern and two secure rectangles (top-left and bottom-right): red on top, blue below (no mirroring); both rectangles black for the whole time they were registered and at the right place; after removal the same areas show red and blue; no unmasked frame while registered. Not verified on a device: rotation (the tablet kept portrait with the system rotation settings), OpenGLES2, other Unity lines than 2021.3.45 (fallback) and 6000.0.84 (Unity-side capture); Unity 2022.3 was not built. +- Measured once, on a Galaxy Tab S5e (Android 11, Unity 6000.0.84f1 IL2CPP development build, Vulkan, the sample scene, 400x640 capture at `TargetFps` 5, about 100 s per run, pusher on versus the SDK's default capture): about 4.6 frames/s pushed, 0 failures, 1-3 frames dropped in flight; readback latency about 81-91 ms; `addFrame` 18-19 ms on the push thread. Unity frame rate 37.5-38.1 versus 37.6-37.9 fps, frame time p50 33.1 ms and p95 33.2 ms in both. CPU is comparable, not lower: the push thread used about 10% of a core, against about 9% for the default capture's `BugseeScreenCap` thread; main thread about 50-57% either way; total PSS 391-396 MB versus 374-382 MB (about 15 MB more for the render textures and buffers). So it is not lighter than the default capture on this device. One device and one light scene, not a benchmark of heavier games. +- Decoded report videos (640x640 h264 from staging reports UNITYTA-35 to 43; red-top/blue-bottom pattern, a top-left and a bottom-right secure rectangle): red above blue (no mirroring) on both Vulkan and OpenGLES3 (the tablet also ran OpenGLES3 in one build: BottomUp, same result); frames before a rectangle was registered are unmasked, then every frame while it was registered has both rectangles black at the right place, then after removal the same areas show the pattern again, with no unmasked frame inside the masked period and no masked frame after removal. A rectangle added after a rotation (landscape to portrait, driven by `Screen.orientation` from the sample) is masked at the right place, and the view region followed the rotation (2560x1600 to 1600x2560). With the status bar shown the Unity view was 2560x1492; a rectangle at window pixels (0,0)-(800,400) was masked to exactly the area it covers in that view (frame x 0-199, y to 233), i.e. relative to the view, not the window. A launch from Unity 2021.3.45 with DirectBuffers requested logged the fallback warning and recorded a normal default-capture video (UNITYTA-43). A `Bugsee.Launch` from a worker thread with DirectBuffers works (after a fix: `AndroidBridge.Launch` read Application.buildGUID off the main thread). Not verified on a device: a view whose top offset is non-zero (the status bar here sat at the bottom), split-screen or a display cutout, the fail-closed blanking when the view position cannot be read (unit tests only), OpenGLES2, Unity 2022.3 and Unity 2022.2 (not built), and heavier scenes. diff --git a/Runtime/Platform/Android/AndroidBridge.cs b/Runtime/Platform/Android/AndroidBridge.cs index b135f3a..aa693b2 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -81,10 +81,15 @@ AndroidJavaObject CurrentActivity() // request was superseded, or no result came within AndroidLifecycleMachine.OperationTimeout. internal void Launch(string appToken, IDictionary options, Action completion) { - ManagedExceptionPayload.EnsureBuildIdentity(); - EnsureWrapperRegistered(); - InstallDefaultListeners(); - MainThreadDispatcher.Run(() => Lifecycle.Launch(appToken, options, completion)); + // All of it on the main thread: build identity reads Application.buildGUID/version and Resources, which throw + // UnityException when Launch is called from another thread. + MainThreadDispatcher.Run(() => + { + ManagedExceptionPayload.EnsureBuildIdentity(); + EnsureWrapperRegistered(); + InstallDefaultListeners(); + Lifecycle.Launch(appToken, options, completion); + }); } public void Relaunch(IDictionary options) => Relaunch(options, null); diff --git a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs index 7092bba..ed22955 100644 --- a/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs +++ b/Samples~/BugseeMiniGame/Assets/Scripts/BugseeHarness/BugseeSampleBootstrap.cs @@ -158,7 +158,7 @@ public void LaunchSdk() // "bugsee_status_bar" = 1 shows the status bar, so Unity's view no longer fills the window. if (ReadE2EValue("bugsee_status_bar", "BUGSEE_STATUS_BAR") == "1") Screen.fullScreen = false; - // "bugsee_orient_after_s" = "12" rotates the activity to landscape after that many seconds (no sensor needed). + // "bugsee_orient_after_s" = "12" rotates the activity to the other orientation after that many seconds (no sensor needed). var orientAfter = ReadE2EValue("bugsee_orient_after_s", "BUGSEE_ORIENT_AFTER_S"); if (!string.IsNullOrEmpty(orientAfter)) StartCoroutine(RotateAfter(orientAfter)); @@ -429,8 +429,10 @@ IEnumerator RotateAfter(string seconds) { if (!float.TryParse(seconds, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var at)) yield break; yield return new WaitForSeconds(at); - Screen.orientation = ScreenOrientation.LandscapeLeft; - Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2") + " rotated to landscape"); + // To the other orientation than the one it is in now. + var toPortrait = Screen.width > Screen.height; + Screen.orientation = toPortrait ? ScreenOrientation.Portrait : ScreenOrientation.LandscapeLeft; + Debug.Log("[BugseeSample] t=" + Time.realtimeSinceStartup.ToString("F2") + (toPortrait ? " rotated to portrait" : " rotated to landscape")); } IEnumerator SecureRectDemo(string fractionsList, string removeAfterSeconds, string delaySeconds) From 60b6f5cd24f04d068ab1b6ed2d98cbfaa2b12b80 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:39:18 +0500 Subject: [PATCH 138/157] Test: Launch setup runs inside the main-thread hop. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 0c4961a..09a0072 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -309,7 +309,11 @@ public void Android_delete_collected_data_stops_then_deletes() // stale-result and deadline rules are covered by AndroidLaunchStatePolicyTests. string bridge = android; Assert.That(ExtractMethodBody(bridge, "internal void Launch(string appToken, IDictionary options, Action completion)"), - Does.Contain("MainThreadDispatcher.Run(() => Lifecycle.Launch(appToken, options, completion))")); + Does.Contain("Lifecycle.Launch(appToken, options, completion);")); + // The identity/wrapper setup reads main-thread-only Unity APIs, so it must run inside the main-thread hop. + string launchBody = ExtractMethodBody(bridge, "internal void Launch(string appToken, IDictionary options, Action completion)"); + Assert.That(launchBody.IndexOf("MainThreadDispatcher.Run", StringComparison.Ordinal), + Is.LessThan(launchBody.IndexOf("ManagedExceptionPayload.EnsureBuildIdentity()", StringComparison.Ordinal))); Assert.That(ExtractMethodBody(bridge, "internal void Relaunch(IDictionary options, Action completion)"), Does.Contain("Lifecycle.Relaunch(options, completion)")); Assert.That(ExtractMethodBody(bridge, "void Stop(Action completion, Action finished)"), From 15c8d21cd284c246bdcb5a9786c771ea1454907c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:44:02 +0500 Subject: [PATCH 139/157] Final delta: iOS Launch identity on the main thread, plausible Unity view check, deferred fallback keeps the latest options. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #22 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 6 +++--- Runtime/Capture/BugseeFrameCapturer.cs | 2 ++ Runtime/Platform/IOS/IOSBridge.cs | 7 +++++-- Runtime/WrapperPolicy/CaptureStartup.cs | 5 +++++ Runtime/WrapperPolicy/FramePushPolicy.cs | 13 +++++++++++++ Tests~/WrapperPolicy/CaptureStartupTests.cs | 16 ++++++++++++++++ Tests~/WrapperPolicy/FramePushPolicyTests.cs | 15 ++++++++++++++- Tests~/WrapperPolicy/PublicApiSourceTests.cs | 5 +++++ 9 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1f9d2..d778640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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. Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). +- 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. - Initial UPM package scaffold (`com.bugsee.unity`). diff --git a/Documentation~/index.md b/Documentation~/index.md index db792f9..787cc0b 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -236,7 +236,7 @@ Not benchmarked beyond one tablet; do not enable it in production without measur **Unity versions.** Supported: release builds (channel `f` or `p`) of Unity 2022.2.0f1 and newer, Android player builds, with the Screen Capture module. `AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11 (checked against Unity's reference source: 2022.2.0a1, 2022.1 and 2021.3 do not have it; 2022.2.0b1, all of 2022.3 LTS and 6000.x do), but alpha and beta builds are treated as unsupported. **Not supported: Unity 2021.3 and 2022.1.** The only alternatives there (a Java `byte[]` per frame, or a file) were measured and rejected (about 0.9 s per 1 MB frame, or disk writes). The minimum is one constant, `UnityCapturePolicy.MinimumUnityVersion`, plus the `BUGSEE_UNITY_CAPTURE` version define (`2022.2.0`) in `Bugsee.Runtime.asmdef`. -**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: Unity that is not a release build of 2022.2.0f1 or newer, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are measured; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch was accepted (then the wrapper relaunches with the default capture, unless a newer Launch/Relaunch is waiting, which then decides). The capturer follows what the native SDK actually did: an ignored Launch (SDK already running) or a refused one leaves it alone, and an accepted one is read back from the SDK's effective launch options. Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. +**Falling back to the default capture.** `VideoMode.DirectBuffers` means "the app pushes the frames", and in Unity the only frame source is this capturer. So you ask for it with the launch option alone (no `Enable()` call needed): `Bugsee.Launch` / `Relaunch` start the capturer, with `BugseeFrameCapturer.DefaultOptions` (set it before launching). If it cannot run, the wrapper itself, before the options reach the native SDK, replaces `DirectBuffers` with the default capture (`VideoMode.V2`) and logs one warning with the reason, so the session never runs in `DirectBuffers` without a frame source (that records no real video). Reasons: the player was not probed yet (a launch from another thread before Unity finished starting), Unity that is not a release build of 2022.2.0f1 or newer, no Screen Capture module, no `AsyncGPUReadback` on the device, a graphics API whose readback row order was not measured (OpenGLES3 and Vulkan are measured; set `DefaultOptions.RowOrder` to declare another), or the capturer failing to start after the launch was accepted (then the wrapper relaunches with the default capture, unless a newer Launch/Relaunch is waiting, which then decides). The capturer follows what the native SDK actually did: an ignored Launch (SDK already running) or a refused one leaves it alone, and an accepted one is read back from the SDK's effective launch options. Other video modes are never touched. Apps that push frames themselves through the native `getVideoFrameConsumer()` are affected too: use a native mode there or this fallback applies. ```csharp // using Bugsee.Capture; @@ -252,9 +252,9 @@ What to know before using it: - Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. - It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. -- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (top-left origin). They are mapped relative to where Unity's view sits in the window (its position and size, re-read about once a second, so a status bar, cutout or multi-window offset is accounted for) and scaled to the frame; if that position cannot be read, the whole frame is blanked while a rectangle is registered. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. +- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (top-left origin). They are mapped relative to where Unity's view sits in the window (its position and size, re-read about once a second, so a status bar, cutout or multi-window offset is accounted for) and scaled to the frame; if that position cannot be read, or the surface found does not have the aspect ratio of Unity's screen (a plugin's SurfaceView, or a view caught mid-rotation), the whole frame is blanked while a rectangle is registered. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. - Row order of the readback was measured for Vulkan (Galaxy Tab S5e, Android 11, Adreno 615, Unity 6000.0.84f1: TopDown) and for OpenGLES3 (Android 16 arm64 emulator, swiftshader, Unity 6000.0.84f1: BottomUp, with a red-over-blue pattern; no GLES3 device was measured). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (not reachable on supported versions). After 5 failed readbacks in a row it turns itself off. - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. - Measured once, on a Galaxy Tab S5e (Android 11, Unity 6000.0.84f1 IL2CPP development build, Vulkan, the sample scene, 400x640 capture at `TargetFps` 5, about 100 s per run, pusher on versus the SDK's default capture): about 4.6 frames/s pushed, 0 failures, 1-3 frames dropped in flight; readback latency about 81-91 ms; `addFrame` 18-19 ms on the push thread. Unity frame rate 37.5-38.1 versus 37.6-37.9 fps, frame time p50 33.1 ms and p95 33.2 ms in both. CPU is comparable, not lower: the push thread used about 10% of a core, against about 9% for the default capture's `BugseeScreenCap` thread; main thread about 50-57% either way; total PSS 391-396 MB versus 374-382 MB (about 15 MB more for the render textures and buffers). So it is not lighter than the default capture on this device. One device and one light scene, not a benchmark of heavier games. -- Decoded report videos (640x640 h264 from staging reports UNITYTA-35 to 43; red-top/blue-bottom pattern, a top-left and a bottom-right secure rectangle): red above blue (no mirroring) on both Vulkan and OpenGLES3 (the tablet also ran OpenGLES3 in one build: BottomUp, same result); frames before a rectangle was registered are unmasked, then every frame while it was registered has both rectangles black at the right place, then after removal the same areas show the pattern again, with no unmasked frame inside the masked period and no masked frame after removal. A rectangle added after a rotation (landscape to portrait, driven by `Screen.orientation` from the sample) is masked at the right place, and the view region followed the rotation (2560x1600 to 1600x2560). With the status bar shown the Unity view was 2560x1492; a rectangle at window pixels (0,0)-(800,400) was masked to exactly the area it covers in that view (frame x 0-199, y to 233), i.e. relative to the view, not the window. A launch from Unity 2021.3.45 with DirectBuffers requested logged the fallback warning and recorded a normal default-capture video (UNITYTA-43). A `Bugsee.Launch` from a worker thread with DirectBuffers works (after a fix: `AndroidBridge.Launch` read Application.buildGUID off the main thread). Not verified on a device: a view whose top offset is non-zero (the status bar here sat at the bottom), split-screen or a display cutout, the fail-closed blanking when the view position cannot be read (unit tests only), OpenGLES2, Unity 2022.3 and Unity 2022.2 (not built), and heavier scenes. +- Decoded report videos (640x640 h264 from staging reports UNITYTA-35 to 43; red-top/blue-bottom pattern, a top-left and a bottom-right secure rectangle): red above blue (no mirroring) on both Vulkan and OpenGLES3 (the tablet also ran OpenGLES3 in one build: BottomUp, same result); frames before a rectangle was registered are unmasked, then every frame while it was registered has both rectangles black at the right place, then after removal the same areas show the pattern again, with no unmasked frame inside the masked period and no masked frame after removal. A rectangle added after a rotation (landscape to portrait, driven by `Screen.orientation` from the sample) is masked at the right place, and the view region followed the rotation (2560x1600 to 1600x2560). With the status bar shown the Unity view was 2560x1492; a rectangle at window pixels (0,0)-(800,400) was masked to exactly the area it covers in that view (frame x 0-199, y to 233), i.e. relative to the view, not the window. A launch from Unity 2021.3.45 with DirectBuffers requested logged the fallback warning and recorded a normal default-capture video (UNITYTA-43). A `Bugsee.Launch` from a worker thread with DirectBuffers works (after a fix: `AndroidBridge.Launch` read Application.buildGUID off the main thread). Limits: the wrapper learns a launch's outcome only until the lifecycle deadline (10 s); if the native SDK answers a request later than that, the answer is not delivered, the pusher follows the earlier refusal, and a native session that then runs in DirectBuffers without a pusher is not detected. Not verified on a device: a view whose top offset is non-zero (the status bar here sat at the bottom), split-screen or a display cutout, the fail-closed blanking when the view position cannot be read (unit tests only), OpenGLES2, Unity 2022.3 and Unity 2022.2 (not built), and heavier scenes. diff --git a/Runtime/Capture/BugseeFrameCapturer.cs b/Runtime/Capture/BugseeFrameCapturer.cs index d768d82..99c0dcb 100644 --- a/Runtime/Capture/BugseeFrameCapturer.cs +++ b/Runtime/Capture/BugseeFrameCapturer.cs @@ -455,6 +455,8 @@ SourceRegion ReadViewRegion() AndroidJNI.DeleteLocalRef(surface); if (ClearPendingException()) return default; var region = new SourceRegion(left, top, width, height); + // Not Unity's surface (a plugin's SurfaceView, or a view caught mid-rotation): unknown, so masking fails closed. + if (!FramePushPolicy.IsPlausibleUnityView(width, height, Screen.width, Screen.height)) return default; if (_options.LogStats && (region.Left != _loggedRegion.Left || region.Top != _loggedRegion.Top || region.Width != _loggedRegion.Width || region.Height != _loggedRegion.Height)) { diff --git a/Runtime/Platform/IOS/IOSBridge.cs b/Runtime/Platform/IOS/IOSBridge.cs index d0ca501..0ec5f1e 100644 --- a/Runtime/Platform/IOS/IOSBridge.cs +++ b/Runtime/Platform/IOS/IOSBridge.cs @@ -62,9 +62,12 @@ void ILifecycleResultBridge.Launch(string appToken, IDictionary void Launch(string appToken, IDictionary options, Action started) { - ManagedExceptionPayload.EnsureBuildIdentity(); + // Build identity reads Application.buildGUID/version and Resources: main thread only, so inside the hand-off. if (!MainThreadDispatcher.RunSyncLifecycle(() => - LaunchOrDefer("Launch", () => LaunchNow(appToken, options, started), started))) + { + ManagedExceptionPayload.EnsureBuildIdentity(); + LaunchOrDefer("Launch", () => LaunchNow(appToken, options, started), started); + })) { Debug.LogError("[Bugsee] Launch timed out waiting for the Unity main thread."); RunResult(started, false); diff --git a/Runtime/WrapperPolicy/CaptureStartup.cs b/Runtime/WrapperPolicy/CaptureStartup.cs index bdddad0..b73f1cc 100644 --- a/Runtime/WrapperPolicy/CaptureStartup.cs +++ b/Runtime/WrapperPolicy/CaptureStartup.cs @@ -156,6 +156,11 @@ public static void OnLaunchResult( warn("the Unity-side frame pusher had stopped; relaunching with the SDK's default capture."); relaunch(UnityCapturePolicy.WithFallback(options ?? deferred)); } + else + { + // Still not the last request: keep this session's options for the fallback that is still owed. + state.DeferFallback(options); + } return; } diff --git a/Runtime/WrapperPolicy/FramePushPolicy.cs b/Runtime/WrapperPolicy/FramePushPolicy.cs index 28579f9..0765b7e 100644 --- a/Runtime/WrapperPolicy/FramePushPolicy.cs +++ b/Runtime/WrapperPolicy/FramePushPolicy.cs @@ -100,6 +100,19 @@ public void Complete(bool pushed) } } + /// + /// Sanity check of the view the capturer found (the first SurfaceView under the content view) against Unity's + /// screen: Unity renders at a uniform scale of its own view, so the aspect ratios must agree. A plugin's + /// SurfaceView ahead of Unity's, or a view caught mid-rotation, fails it and masking fails closed. + /// + public static bool IsPlausibleUnityView(int viewWidth, int viewHeight, int screenWidth, int screenHeight) + { + if (viewWidth <= 0 || viewHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) return false; + double view = (double)viewWidth / viewHeight; + double screen = (double)screenWidth / screenHeight; + return Math.Abs(view - screen) <= 0.02 * screen; + } + /// A failure outside a request (an exception while preparing one): counts toward the self-disable streak. public void RecordFailure() { diff --git a/Tests~/WrapperPolicy/CaptureStartupTests.cs b/Tests~/WrapperPolicy/CaptureStartupTests.cs index e0b7a54..2c9cab2 100644 --- a/Tests~/WrapperPolicy/CaptureStartupTests.cs +++ b/Tests~/WrapperPolicy/CaptureStartupTests.cs @@ -322,6 +322,22 @@ public void A_start_failure_with_a_newer_request_waiting_is_deferred_not_dropped Assert.That(f.Relaunches, Has.Count.EqualTo(1)); } + [Test] + public void A_launch_accepted_with_direct_buffers_but_not_the_latest_keeps_its_options_for_the_owed_fallback() + { + var f = FailedWhileWaiting(); + // request A is answered (accepted, DirectBuffers, options "other"=2) but request B is still waiting + f.Latest = false; + f.Result(true, true, new Dictionary { { Options.CaptureVideoMode, (int)VideoMode.DirectBuffers }, { "other", 2 } }); + Assert.That(f.Relaunches, Is.Empty); + Assert.That(f.State.FallbackPending, Is.True); + // B is ignored: the fallback runs on top of A's options, not the first session's + f.Latest = true; + f.Result(false, false, Mode(VideoMode.V2)); + Assert.That(f.Relaunches, Has.Count.EqualTo(1)); + Assert.That(f.Relaunches[0]["other"], Is.EqualTo(2)); + } + [Test] public void Stopping_the_session_cancels_a_deferred_fallback() { diff --git a/Tests~/WrapperPolicy/FramePushPolicyTests.cs b/Tests~/WrapperPolicy/FramePushPolicyTests.cs index dbadd3c..6af751c 100644 --- a/Tests~/WrapperPolicy/FramePushPolicyTests.cs +++ b/Tests~/WrapperPolicy/FramePushPolicyTests.cs @@ -332,7 +332,20 @@ public void Extreme_rectangle_values_do_not_overflow_into_the_frame_or_out_of_it for (int y = 0; y < H; y++) for (int x = 0; x < W; x++) Assert.That(Blank(d, x, y), Is.True); var e = White(); FrameRedaction.BlankRects(e, W, H, VideoFrameRowOrder.TopDown, new[] { int.MinValue, 0, 2, 1 }, 1, new SourceRegion(int.MaxValue, 0, 4, 4)); - Assert.That(Untouched(e, 0, 0) || Blank(e, 0, 0), Is.True); + Assert.That(Blank(e, 0, 0), Is.False, "far outside the view: left alone, no wrap-around"); + Assert.That(Untouched(e, 0, 0), Is.True); + } + + [TestCase(1600, 2560, 1600, 2560, true)] + [TestCase(1600, 2560, 800, 1280, true)] // reduced render resolution, same aspect + [TestCase(2560, 1492, 2560, 1492, true)] + [TestCase(2560, 1600, 1600, 2560, false)] // mid-rotation or the wrong view + [TestCase(1000, 200, 1600, 2560, false)] // a banner-like plugin surface + [TestCase(0, 0, 1600, 2560, false)] + [TestCase(1600, 2560, 0, 0, false)] + public void Unity_view_plausibility_matches_the_screen_aspect(int vw, int vh, int sw, int sh, bool expected) + { + Assert.That(FramePushPolicy.IsPlausibleUnityView(vw, vh, sw, sh), Is.EqualTo(expected)); } [Test] diff --git a/Tests~/WrapperPolicy/PublicApiSourceTests.cs b/Tests~/WrapperPolicy/PublicApiSourceTests.cs index 09a0072..f8e9e25 100644 --- a/Tests~/WrapperPolicy/PublicApiSourceTests.cs +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -310,6 +310,11 @@ public void Android_delete_collected_data_stops_then_deletes() string bridge = android; Assert.That(ExtractMethodBody(bridge, "internal void Launch(string appToken, IDictionary options, Action completion)"), Does.Contain("Lifecycle.Launch(appToken, options, completion);")); + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + string iosLaunch = ExtractMethodBody(ios, "void Launch(string appToken, IDictionary options, Action started)"); + Assert.That(iosLaunch.IndexOf("RunSyncLifecycle", StringComparison.Ordinal), + Is.LessThan(iosLaunch.IndexOf("ManagedExceptionPayload.EnsureBuildIdentity()", StringComparison.Ordinal)), + "iOS Launch must read the build identity inside the main-thread hand-off"); // The identity/wrapper setup reads main-thread-only Unity APIs, so it must run inside the main-thread hop. string launchBody = ExtractMethodBody(bridge, "internal void Launch(string appToken, IDictionary options, Action completion)"); Assert.That(launchBody.IndexOf("MainThreadDispatcher.Run", StringComparison.Ordinal), From 828634e936b058a79b9a5309307217e1e2ea8242 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:56:43 +0500 Subject: [PATCH 140/157] Patch the generated Gradle project instead of creating templates; remove legacy ones. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #80. No Gradle template is created in Assets any more (nothing to go stale on Unity upgrades): the Bugsee Gradle plugin is patched into the generated root/launcher build.gradle when the generated project pins AGP 8.6+, and 2021.3 release builds get debugSymbolLevel FULL. Exact templates written by earlier package versions are removed on Android builds when Unity's default AGP major differs; user-authored templates only get an actionable warning, only during Android builds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 3 +- Documentation~/index.md | 2 +- Editor/BugseeAndroidGradleSetup.cs | 284 +++++++----------- README.md | 2 +- .../WrapperPolicy/GradleTemplateDefaults.cs | 125 +++++++- .../LegacyPackage/base-no-plugin.gradle | 14 + .../LegacyPackage/base-with-plugin.gradle | 15 + .../LegacyPackage/launcher-no-plugin.gradle | 65 ++++ .../LegacyPackage/launcher-with-plugin.gradle | 73 +++++ .../LegacyPackage/user-authored-base.gradle | 15 + .../GradleTemplateDefaultsTests.cs | 57 +++- 11 files changed, 472 insertions(+), 183 deletions(-) create mode 100644 Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-no-plugin.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-with-plugin.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-no-plugin.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-with-plugin.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/LegacyPackage/user-authored-base.gradle diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffa11e..616c936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Android builds on Unity 6 failed in Gradle (`DependencyHandler.module(java.lang.Object)` while evaluating `:launcher`): the package wrote its own `Assets/Plugins/Android/baseProjectTemplate.gradle` and `launcherTemplate.gradle` pinning Android Gradle plugin 7.4.2, which cannot run on Unity 6's Gradle 9. When a project has no such templates the package now copies the ones the installed Unity ships (AGP 7.4.2 on 2021.3, 9.0.0 on 6000.0.84f1) and only adds the Bugsee Gradle plugin lines (Unity 6+) and, on 2021.3, release `debugSymbolLevel 'FULL'`. A template that pins another AGP major than Unity's default (for example one left by an earlier version of this package) logs a warning. The sample no longer ships custom Gradle templates. Verified with real IL2CPP arm64 development builds on 2021.3.45f1 and 6000.0.84f1; Bugsee Gradle plugin 4.0.8 works with Unity 6's AGP 9.0.0 / Gradle 9.3.1. +- Android builds on Unity 6 failed in Gradle (`DependencyHandler.module(java.lang.Object)` while evaluating `:launcher`): the package wrote its own `Assets/Plugins/Android/baseProjectTemplate.gradle` and `launcherTemplate.gradle` pinning Android Gradle plugin 7.4.2, which cannot run on Unity 6's Gradle 9. The package no longer creates Gradle templates. It patches the Gradle project Unity generates instead (so nothing in Assets goes stale on Unity upgrades): the Bugsee Gradle plugin is added when the generated project pins AGP 8.6+ (previously: any Unity 6), and on 2021.3 the release build type gets `debugSymbolLevel 'FULL'`. The exact templates earlier package versions wrote are removed on an Android build when Unity's default AGP is another major; any other template pinning a different AGP major is left alone with a warning naming the file and the fix. The sample no longer ships custom Gradle templates. Verified with real IL2CPP arm64 builds on 2021.3.45f1 and 6000.0.84f1; Bugsee Gradle plugin 4.0.8 works with Unity 6's AGP 9.0.0 / Gradle 9.3.1. +- Android native symbols on Unity 6: release builds no longer force `debugSymbolLevel 'FULL'` in the launcher; the level follows Player Settings (Create symbols.zip). Symbol upload reads Unity's `symbols.zip`; a Unity 6 release build with `Create symbols.zip` = Public (as the sample's E2E build uses) yields public symbol tables only (symbols.zip about 18 MB vs about 230 MB with Debugging), so set Debugging for line-level native symbolication. - Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. - iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. diff --git a/Documentation~/index.md b/Documentation~/index.md index 4530022..7719c1f 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -207,7 +207,7 @@ Managed error/crash **grouping** (one signature per event, Unity-primary when st ## Native dependencies -- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1`, declared in the generated Gradle project by `Editor/BugseeAndroidMavenDependencies.cs` (Gradle plugin `4.0.8`). Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. +- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1`, declared in the generated Gradle project by `Editor/BugseeAndroidMavenDependencies.cs` (Gradle plugin `4.0.8`, patched into the generated project when it pins AGP 8.6+). Native symbols for upload come from Unity's `symbols.zip` (Player Settings: Create symbols.zip); on Unity 6 the launcher's `debugSymbolLevel` follows Player Settings (choose Debugging for line-level native symbolication; Public gives symbol tables only), on 2021.3 release builds get `FULL`. Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. - **iOS:** SPM-only. Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` (iOS 15+). C bridge: `Plugins/iOS/BugseeUnityBridge.mm` + `BugseeUnityCallbacks.mm`. ### iOS bridge status diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 7757855..33e7cdc 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -2,6 +2,7 @@ using System.IO; using Bugsee.WrapperPolicy; using UnityEditor; +using UnityEditor.Android; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; @@ -9,15 +10,20 @@ namespace Bugsee.Editor { /// - /// Wires the Bugsee Android Gradle plugin into Unity's launcher module. - /// Instrumentation only runs on com.android.application (not unityLibrary). + /// Wires the Bugsee Android Gradle plugin into Unity's launcher module (instrumentation only runs on + /// com.android.application, not unityLibrary). Plugin 4.x needs AGP 8.6+ / Gradle 8.7+. /// - /// Plugin 4.x requires AGP ≥ 8.6 / Gradle ≥ 8.7 (Unity 6+). On Unity 2021.3 the - /// plugin apply is skipped by default. The runtime AARs (bugsee-android + - /// bugsee-android-ndk) are declared by . + /// The package creates no Gradle template. It patches the Gradle project Unity generated (the hook + /// already uses): the plugin goes into the root + /// plugins {} block and the launcher build.gradle, only when the generated root file pins AGP 8.6+. + /// So there is no file in Assets to go stale across Unity upgrades, and the AGP/Gradle versions are always the + /// installed Unity's own. A project's own custom templates are patched the same way as Unity's defaults. + /// Earlier package versions wrote hand-made AGP 7.4.2 templates into Assets/Plugins/Android; those exact files + /// () are removed when this Unity's default AGP + /// is another major, since Gradle in Unity 6 cannot run them. Any other template is left alone and only warned about. + /// The runtime AARs (bugsee-android + bugsee-android-ndk) are declared by . /// - [InitializeOnLoad] - sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport + sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport, IPostGenerateGradleAndroidProject { public int callbackOrder => 10; @@ -25,226 +31,168 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport public const string SdkVersion = BugseePackageVersion.AndroidSdkVersion; const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; - const string KotlinExcludeMarker = "// Bugsee: bugsee-android Gradle .module"; - - static BugseeAndroidGradleSetup() - { - EditorApplication.delayCall += () => - { - if (!EditorApplication.isPlayingOrWillChangePlaymode) - { - EnsureProjectTemplates(silent: true); - } - }; - } public void OnPreprocessBuild(BuildReport report) { if (report.summary.platform == BuildTarget.Android) { - EnsureProjectTemplates(silent: false); + RemoveLegacyTemplates(); } } - [MenuItem("Bugsee/Android/Ensure Gradle Plugin Templates")] - static void MenuEnsure() + [MenuItem("Bugsee/Android/Remove Legacy Gradle Templates")] + static void MenuRemoveLegacy() { - EnsureProjectTemplates(silent: false); - Debug.Log( - "[Bugsee] Android Gradle templates refreshed. " + - "Enable Custom Base / Launcher Gradle Template under Player Settings → Publishing Settings if needed."); + RemoveLegacyTemplates(); + Debug.Log("[Bugsee] Checked Assets/Plugins/Android for Gradle templates written by earlier package versions."); } - [MenuItem("Bugsee/Android/Force Gradle Plugin On Older Unity (unsupported)")] + [MenuItem("Bugsee/Android/Force Gradle Plugin On Older AGP (unsupported)")] static void MenuForcePlugin() { bool next = !EditorPrefs.GetBool(PrefForcePlugin, false); EditorPrefs.SetBool(PrefForcePlugin, next); Debug.Log( "[Bugsee] Force Gradle plugin = " + next + - ". Plugin 4.x needs AGP 8.6+; forcing on Unity 2021.3 will likely fail Gradle configuration."); - EnsureProjectTemplates(silent: false); + ". Plugin 4.x needs AGP 8.6+; forcing it on an older AGP will likely fail Gradle configuration."); } - internal static void EnsureProjectTemplates(bool silent) + public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) { - Directory.CreateDirectory(Path.Combine(Application.dataPath, "Plugins", "Android")); - - bool applyPlugin = ShouldApplyGradlePlugin(); - WriteBaseProjectTemplate(applyPlugin); - WriteLauncherTemplate(applyPlugin); - RemoveStaleKotlinStdlibExclusion(); - - if (silent) - { - return; - } - - if (applyPlugin) + try { - Debug.Log( - $"[Bugsee] Launcher applies com.bugsee.android.gradle:{GradlePluginVersion}. " + - $"Runtime AAR pin {SdkVersion} with NDK enabled."); + PatchGeneratedProject(Path.GetDirectoryName(Path.GetFullPath(unityLibraryPath))); } - else + catch (Exception ex) when (!(ex is BuildFailedException)) { - Debug.LogWarning( - "[Bugsee] Bugsee Gradle plugin not applied (needs AGP 8.6+ / Unity 6+). " + - "bugsee-android + bugsee-android-ndk are still declared in the generated Gradle project."); + Debug.LogWarning("[Bugsee] Could not patch the generated Gradle project for the Bugsee Gradle plugin: " + ex.Message); } } - /// - /// 7.1.4+ (including 7.4.1) ships empty POM and Gradle .module metadata. Drop the kotlin-stdlib - /// exclusion previously patched into mainTemplate for older .module files. - /// - static void RemoveStaleKotlinStdlibExclusion() + static void PatchGeneratedProject(string projectRoot) { - string path = Path.Combine(Application.dataPath, "Plugins", "Android", "mainTemplate.gradle"); - if (!File.Exists(path)) + string rootPath = Path.Combine(projectRoot, "build.gradle"); + string launcherPath = Path.Combine(projectRoot, "launcher", "build.gradle"); + if (!File.Exists(rootPath) || !File.Exists(launcherPath)) { + Debug.LogWarning( + "[Bugsee] No Groovy root/launcher build.gradle in the generated Gradle project; " + + "the Bugsee Gradle plugin is not applied."); return; } - string text = File.ReadAllText(path); - int start = text.IndexOf(KotlinExcludeMarker, StringComparison.Ordinal); - if (start < 0) - { - return; - } + string root = File.ReadAllText(rootPath); + string launcher = File.ReadAllText(launcherPath); - if (start > 0 && text[start - 1] == '\n') + // 2021.3's template sets no native symbol level; 6000.x sets it from Player Settings and is left alone. + string patchedLauncher = GradleTemplateDefaults.EnsureReleaseDebugSymbols(launcher); + + bool force = EditorPrefs.GetBool(PrefForcePlugin, false); + bool apply = force || GradleTemplateDefaults.AgpMeetsBugseePlugin(root); + string patchedRoot = root; + if (apply) { - start--; + patchedRoot = TryApplyMarkedGradle( + rootPath, + root, + "plugins {", + $" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false // bugsee:gradle-plugin"); + patchedLauncher = TryApplyMarkedGradle( + launcherPath, + patchedLauncher, + "apply plugin: 'com.android.application'", + LauncherMarkedGradlePatch()); } - int brace = text.IndexOf("configurations.configureEach", start, StringComparison.Ordinal); - if (brace < 0) + if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { - return; + File.WriteAllText(rootPath, patchedRoot); } - int end = text.IndexOf('}', brace); - if (end < 0) + if (!string.Equals(launcher, patchedLauncher, StringComparison.Ordinal)) { - return; + File.WriteAllText(launcherPath, patchedLauncher); } - end++; - if (end < text.Length && text[end] == '\n') + if (apply) { - end++; + Debug.Log( + $"[Bugsee] Launcher applies com.bugsee.android.gradle:{GradlePluginVersion}. " + + $"Runtime AAR pin {SdkVersion} with NDK enabled."); } - - text = text.Remove(start, end - start); - WriteIfChanged(path, text); - } - - static bool ShouldApplyGradlePlugin() - { - if (EditorPrefs.GetBool(PrefForcePlugin, false)) + else { - return true; + Debug.Log( + "[Bugsee] Bugsee Gradle plugin not applied: it needs AGP 8.6+ and the generated project pins " + + "an older or unreadable version. bugsee-android + bugsee-android-ndk are still declared in the generated Gradle project."); } - - // Unity 6 == 6000.x ships AGP 8.x, which meets plugin 4.x requirements. - return GetUnityMajorVersion() >= 6000; } - static int GetUnityMajorVersion() - { - string head = Application.unityVersion.Split('f')[0]; - string major = head.Split('.')[0]; - return int.TryParse(major, out int v) ? v : 0; - } - - static void WriteBaseProjectTemplate(bool applyPlugin) + static string UnityTemplatePath(string name) { - string path = Path.Combine(Application.dataPath, "Plugins", "Android", "baseProjectTemplate.gradle"); - string text = ReadOrCopyUnityDefault(path, "baseProjectTemplate.gradle"); - if (text == null) + try { - return; + // The Android module is not inside Unity.app on macOS, so ask Unity where it lives. + string engine = BuildPipeline.GetPlaybackEngineDirectory(BuildTarget.Android, BuildOptions.None); + return string.IsNullOrEmpty(engine) ? null : Path.Combine(engine, "Tools", "GradleTemplates", name); } - - WarnIfIncompatibleAgp(path, text, "baseProjectTemplate.gradle"); - if (applyPlugin) + catch (Exception) { - TryApplyMarkedGradle( - path, - "plugins {", - $" id 'com.bugsee.android.gradle' version '{GradlePluginVersion}' apply false // bugsee:gradle-plugin"); + return null; } } - static void WriteLauncherTemplate(bool applyPlugin) + /// Android build time only (never on editor reload, so iOS-only projects see nothing). + internal static void RemoveLegacyTemplates() { - string path = Path.Combine(Application.dataPath, "Plugins", "Android", "launcherTemplate.gradle"); - bool existed = File.Exists(path); - string text = ReadOrCopyUnityDefault(path, "launcherTemplate.gradle"); - if (text == null) + string dir = Path.Combine(Application.dataPath, "Plugins", "Android"); + string basePath = Path.Combine(dir, "baseProjectTemplate.gradle"); + string launcherPath = Path.Combine(dir, "launcherTemplate.gradle"); + string unityBase = UnityTemplatePath("baseProjectTemplate.gradle"); + if (!File.Exists(basePath) || unityBase == null || !File.Exists(unityBase)) { return; } - if (!existed) + string projectBase = File.ReadAllText(basePath); + string unityDefault = File.ReadAllText(unityBase); + if (!GradleTemplateDefaults.PinsDifferentAgpMajor(projectBase, unityDefault)) { - // Only for the copy made here; an existing template is the project's own. - WriteIfChanged(path, GradleTemplateDefaults.EnsureReleaseDebugSymbols(text)); + return; } - if (applyPlugin) + if (GradleTemplateDefaults.IsLegacyPackageBaseTemplate(projectBase)) { - TryApplyMarkedGradle( - path, - "apply plugin: 'com.android.application'", - LauncherMarkedGradlePatch()); - } - } - - static string UnityTemplatePath(string name) - { - // The module is not inside Unity.app on macOS, so ask Unity where Android Build Support lives. - string engine = BuildPipeline.GetPlaybackEngineDirectory(BuildTarget.Android, BuildOptions.None); - return string.IsNullOrEmpty(engine) ? null : Path.Combine(engine, "Tools", "GradleTemplates", name); - } - - /// - /// The project's template, or a copy of the one this Unity ships (AGP/Gradle versions and the launcher - /// layout differ per Unity line, so the package never writes its own). Null when there is none to copy - /// (no Android Build Support module); the Unity default is then used unmodified. - /// - static string ReadOrCopyUnityDefault(string path, string name) - { - if (File.Exists(path)) - { - return File.ReadAllText(path); - } + DeleteTemplate(basePath, "pinned Android Gradle plugin " + GradleTemplateDefaults.PinnedAgpMajor(projectBase) + + ".x, but Unity " + Application.unityVersion + " builds with " + GradleTemplateDefaults.PinnedAgpMajor(unityDefault) + ".x"); + if (File.Exists(launcherPath) && GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(File.ReadAllText(launcherPath))) + { + DeleteTemplate(launcherPath, "uses the Gradle DSL of Unity 2021.3, which this Unity's Gradle cannot run"); + } - string source = UnityTemplatePath(name); - if (source == null || !File.Exists(source)) - { - Debug.LogWarning("[Bugsee] Unity's default " + name + " was not found (" + source + "); not creating one."); - return null; + return; } - string text = File.ReadAllText(source); - WriteIfChanged(path, text); - return text; + Debug.LogWarning( + "[Bugsee] " + basePath + " pins Android Gradle plugin " + GradleTemplateDefaults.PinnedAgpMajor(projectBase) + + ".x but Unity " + Application.unityVersion + " builds with " + GradleTemplateDefaults.PinnedAgpMajor(unityDefault) + + ".x, so Gradle will fail with \"DependencyHandler.module(java.lang.Object)\" while evaluating :launcher. " + + "This is your own template (not one written by the Bugsee package), so it is left unchanged. " + + "Delete it and Assets/Plugins/Android/launcherTemplate.gradle to use Unity's defaults (Player Settings > Publishing Settings), " + + "or re-create them from " + Path.GetDirectoryName(unityBase) + " and re-apply your edits."); } - static void WarnIfIncompatibleAgp(string path, string projectText, string name) + static void DeleteTemplate(string path, string reason) { - string source = UnityTemplatePath(name); - if (source != null && File.Exists(source) && GradleTemplateDefaults.PinsDifferentAgpMajor(projectText, File.ReadAllText(source))) + string asset = ToAssetPath(path); + if (!AssetDatabase.DeleteAsset(asset)) { - Debug.LogWarning( - "[Bugsee] " + path + " pins Android Gradle plugin " + GradleTemplateDefaults.PinnedAgpMajor(projectText) + - ".x but Unity " + Application.unityVersion + " builds with " + - GradleTemplateDefaults.PinnedAgpMajor(File.ReadAllText(source)) + - ".x, so the Android build will fail in Gradle. Delete the custom template (Bugsee copies Unity's default) " + - "or update the pinned version."); + File.Delete(path); } + + Debug.LogWarning( + "[Bugsee] Removed " + asset + ", written by an earlier Bugsee package version: it " + reason + + ". Unity's default template is used and the Bugsee Gradle plugin is patched into the generated project."); } internal static string LauncherMarkedGradlePatch() @@ -258,32 +206,16 @@ internal static string LauncherMarkedGradlePatch() "} // bugsee:gradle-ndk"; } - static void TryApplyMarkedGradle(string path, string anchorLine, string markedLine) + static string TryApplyMarkedGradle(string path, string text, string anchorLine, string markedLine) { try { - string existing = File.ReadAllText(path); - string updated = MarkedGradleBlock.Apply(existing, anchorLine, markedLine); - WriteIfChanged(path, updated); + return MarkedGradleBlock.Apply(text, anchorLine, markedLine); } catch (InvalidOperationException ex) { - Debug.LogWarning(ex.Message); - } - } - - static void WriteIfChanged(string path, string contents) - { - if (File.Exists(path) && File.ReadAllText(path) == contents) - { - return; - } - - File.WriteAllText(path, contents); - string assetPath = ToAssetPath(path); - if (assetPath.StartsWith("Assets/", StringComparison.Ordinal)) - { - AssetDatabase.ImportAsset(assetPath); + Debug.LogWarning("[Bugsee] Could not add the Bugsee Gradle plugin to " + path + ": " + ex.Message); + return text; } } diff --git a/README.md b/README.md index 9ffd065..7e11c85 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` | Applied on launcher by `BugseeAndroidGradleSetup` on 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 }`) | ### Refreshing native artifacts diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 8f9289b..14a2e5b 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -4,9 +4,10 @@ namespace Bugsee.WrapperPolicy { /// - /// Edits to Unity's own default Gradle templates (copied into Assets/Plugins/Android when a project has none). - /// The package never writes its own copy of a template: the Android Gradle plugin and Gradle versions belong to - /// the Unity line (AGP 7.4.2 on 2021.3, AGP 9.0.0 on Unity 6000.0.84f1) and a pinned copy breaks every other line. + /// Reads and fixes the Gradle files of an Android build. The package creates no Gradle template: the Android + /// Gradle plugin and Gradle versions belong to the Unity line (AGP 7.4.2 on 2021.3, AGP 9.0.0 on 6000.0.84f1) and a + /// pinned copy breaks every other line. It patches the generated project instead, and only removes the template + /// files earlier package versions wrote (, ). /// public static class GradleTemplateDefaults { @@ -53,6 +54,124 @@ public static bool PinsDifferentAgpMajor(string projectTemplate, string unityDef return project > 0 && unity > 0 && project != unity; } + /// The (major, minor) of the AGP a base/root build file pins, or false when it pins none literally. + public static bool TryGetAgpVersion(string text, out int major, out int minor) + { + major = minor = -1; + if (text == null) + { + return false; + } + + Match m = ApplicationPluginVersion.Match(text); + if (!m.Success) + { + m = ClasspathVersion.Match(text); + } + + if (!m.Success) + { + return false; + } + + string[] parts = m.Groups["v"].Value.Split('.'); + if (!int.TryParse(parts[0], out major)) + { + return false; + } + + minor = parts.Length > 1 && int.TryParse(parts[1], out int mi) ? mi : 0; + return true; + } + + /// Bugsee Gradle plugin 4.x needs AGP 8.6 or newer (Gradle 8.7+). False when the AGP version is unknown. + public static bool AgpMeetsBugseePlugin(string rootBuildFile) + { + return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && + (major > 8 || (major == 8 && minor >= 6)); + } + + const string PluginMarker = "bugsee:gradle-plugin"; + + // The baseProjectTemplate.gradle earlier package versions wrote on every Unity line (Unity 2021.3's default + // with its AGP 7.4.2 pin, plus one marked Bugsee plugin line when the plugin was applied). + static readonly string[] LegacyBaseBody = + { + "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", + "}", + }; + + static string[] NormalizedLines(string text) + { + var lines = new System.Collections.Generic.List(); + foreach (string raw in text.Replace("\r\n", "\n").Split('\n')) + { + lines.Add(raw.Trim()); + } + + while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + return lines.ToArray(); + } + + /// + /// True for a base template an earlier package version generated: exactly the AGP 7.4.2 body above, apart from + /// whitespace and the marked Bugsee plugin line. A template with any other content is the project's own. + /// + public static bool IsLegacyPackageBaseTemplate(string text) + { + if (text == null) + { + return false; + } + + var kept = new System.Collections.Generic.List(); + foreach (string line in NormalizedLines(text)) + { + if (line.IndexOf(PluginMarker, StringComparison.Ordinal) < 0) + { + kept.Add(line); + } + } + + return kept.Count == LegacyBaseBody.Length && System.Linq.Enumerable.SequenceEqual(kept, LegacyBaseBody); + } + + /// + /// True for a launcher template an earlier package version generated (Unity 2021.3's Groovy layout, which + /// Unity 6's Gradle cannot run): it carries the package's bugsee:gradle-plugin marker, or the + /// release debugSymbolLevel 'FULL' block the package added to Unity 2021.3's template, and still uses the + /// removed lintOptions/aaptOptions DSL. + /// + public static bool IsLegacyPackageLauncherTemplate(string text) + { + if (text == null) + { + return false; + } + + bool oldDsl = text.IndexOf("lintOptions", StringComparison.Ordinal) >= 0 || + text.IndexOf("aaptOptions", StringComparison.Ordinal) >= 0; + bool ours = text.IndexOf(PluginMarker, StringComparison.Ordinal) >= 0 || + (text.IndexOf("debugSymbolLevel 'FULL'", StringComparison.Ordinal) >= 0 && + text.IndexOf("**LAUNCHER_SOURCE_BUILD_SETUP**", StringComparison.Ordinal) >= 0); + return oldDsl && ours; + } + /// /// Unity 2021.3's default launcher template sets no native debug symbol level, so the release build type /// gets ndk { debugSymbolLevel 'FULL' } (DWARF line programs, needed for the IL2CPP line mapping). diff --git a/Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-no-plugin.gradle b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-no-plugin.gradle new file mode 100644 index 0000000..8a11235 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-no-plugin.gradle @@ -0,0 +1,14 @@ +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/LegacyPackage/base-with-plugin.gradle b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-with-plugin.gradle new file mode 100644 index 0000000..0e0bd91 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/base-with-plugin.gradle @@ -0,0 +1,15 @@ +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 + id 'com.bugsee.android.gradle' version '4.0.8' apply false // bugsee:gradle-plugin + **BUILD_SCRIPT_DEPS** +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + diff --git a/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-no-plugin.gradle b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-no-plugin.gradle new file mode 100644 index 0000000..a6e57eb --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-no-plugin.gradle @@ -0,0 +1,65 @@ +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** + ndk { + debugSymbolLevel 'FULL' + } + } + }**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/Fixtures/LegacyPackage/launcher-with-plugin.gradle b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-with-plugin.gradle new file mode 100644 index 0000000..256dc61 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-with-plugin.gradle @@ -0,0 +1,73 @@ +apply plugin: 'com.android.application' +apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin + +bugsee { + // App token is supplied at runtime via Bugsee.Launch. + ndk { + enabled = true + } +} // bugsee:gradle-ndk + +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** + ndk { + debugSymbolLevel 'FULL' + } + } + }**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/Fixtures/LegacyPackage/user-authored-base.gradle b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/user-authored-base.gradle new file mode 100644 index 0000000..cf52a0b --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/user-authored-base.gradle @@ -0,0 +1,15 @@ +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** + id 'com.google.gms.google-services' version '4.4.0' apply false +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + diff --git a/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs b/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs index 84965ae..06a004e 100644 --- a/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs +++ b/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs @@ -56,6 +56,60 @@ public void Unity_6_launcher_already_sets_a_symbol_level_and_is_left_alone() Assert.That(GradleTemplateDefaults.EnsureReleaseDebugSymbols(template), Is.EqualTo(template)); } + static string Legacy(string name) => + File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "LegacyPackage", name)); + + // Fixtures: the files the package generated before this change (Unity 2021.3 default + AGP 7.4.2 pin, + // with and without the marked plugin lines). + [TestCase("base-no-plugin.gradle")] + [TestCase("base-with-plugin.gradle")] + public void Base_templates_written_by_earlier_package_versions_are_recognised(string name) + { + Assert.That(GradleTemplateDefaults.IsLegacyPackageBaseTemplate(Legacy(name)), Is.True); + Assert.That(GradleTemplateDefaults.IsLegacyPackageBaseTemplate(Legacy(name).Replace("\n", "\r\n")), Is.True); + } + + [Test] + public void A_user_authored_or_unity_6_base_template_is_never_recognised_as_ours() + { + Assert.That(GradleTemplateDefaults.IsLegacyPackageBaseTemplate(Legacy("user-authored-base.gradle")), Is.False); + Assert.That(GradleTemplateDefaults.IsLegacyPackageBaseTemplate(Fixture("6000.0.84f1", "baseProjectTemplate.gradle")), Is.False); + Assert.That(GradleTemplateDefaults.IsLegacyPackageBaseTemplate(null), Is.False); + } + + [TestCase("launcher-no-plugin.gradle")] + [TestCase("launcher-with-plugin.gradle")] + public void Launcher_templates_written_by_earlier_package_versions_are_recognised(string name) + { + Assert.That(GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(Legacy(name)), Is.True); + } + + [Test] + public void Unity_default_launcher_templates_are_not_recognised_as_ours() + { + Assert.That(GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(Fixture("2021.3.45f1", "launcherTemplate.gradle")), Is.False); + Assert.That(GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(Fixture("6000.0.84f1", "launcherTemplate.gradle")), Is.False); + } + + [TestCase("7.4.2", false)] + [TestCase("8.5.2", false)] + [TestCase("8.6.0", true)] + [TestCase("8.10.1", true)] + [TestCase("9.0.0", true)] + public void Bugsee_plugin_is_gated_on_the_pinned_agp_version(string agp, bool expected) + { + string root = "plugins {\n id 'com.android.application' version '" + agp + "' apply false\n}\n"; + Assert.That(GradleTemplateDefaults.AgpMeetsBugseePlugin(root), Is.EqualTo(expected)); + } + + [Test] + public void Unknown_agp_does_not_enable_the_plugin() + { + Assert.That(GradleTemplateDefaults.AgpMeetsBugseePlugin("plugins {\n}\n"), Is.False); + Assert.That(GradleTemplateDefaults.AgpMeetsBugseePlugin(Fixture("6000.0.84f1", "baseProjectTemplate.gradle")), Is.True); + Assert.That(GradleTemplateDefaults.AgpMeetsBugseePlugin(Fixture("2021.3.45f1", "baseProjectTemplate.gradle")), Is.False); + } + [Test] public void Package_editor_code_does_not_pin_an_agp_version_or_write_a_template_by_hand() { @@ -64,7 +118,8 @@ public void Package_editor_code_does_not_pin_an_agp_version_or_write_a_template_ Assert.That(editor, Does.Not.Contain("com.android.application' version")); Assert.That(editor, Does.Not.Contain("StringBuilder")); - Assert.That(editor, Does.Contain("GradleTemplates")); + Assert.That(editor, Does.Not.Contain("File.Copy")); + Assert.That(editor, Does.Contain("IPostGenerateGradleAndroidProject")); } } } From 5f67586a5bfb7cd9f432d631d9a02cd49dad020c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 17:08:57 +0500 Subject: [PATCH 141/157] Warn on native symbol level, opt-in Debugging, never delete edited templates. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round for #80. Android builds warn when a Bugsee token is set and Create symbols.zip is not Debugging; Bugsee > Android menu / BUGSEE_ANDROID_FULL_SYMBOLS=1 builds with Debugging and restores. Legacy template cleanup backs files up to Library/Bugsee/legacy-templates, removes a launcher only when it exactly matches the old generated body, warns for edited ones, also handles a leftover launcher alone, and an unreadable AGP version warns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 3 +- Documentation~/index.md | 2 +- Editor/BugseeAndroidGradleSetup.cs | 103 +++++++++++---- Editor/BugseeAndroidSymbolLevel.cs | 118 ++++++++++++++++++ Editor/BugseeAndroidSymbolLevel.cs.meta | 11 ++ Runtime/WrapperPolicy/AndroidSymbolPolicy.cs | 63 ++++++++++ .../WrapperPolicy/AndroidSymbolPolicy.cs.meta | 11 ++ .../WrapperPolicy/GradleTemplateDefaults.cs | 112 +++++++++++++++-- .../WrapperPolicy/AndroidSymbolPolicyTests.cs | 52 ++++++++ .../LegacyPackage/launcher-customised.gradle | 74 +++++++++++ .../GradleTemplateDefaultsTests.cs | 30 ++++- 11 files changed, 541 insertions(+), 38 deletions(-) create mode 100644 Editor/BugseeAndroidSymbolLevel.cs create mode 100644 Editor/BugseeAndroidSymbolLevel.cs.meta create mode 100644 Runtime/WrapperPolicy/AndroidSymbolPolicy.cs create mode 100644 Runtime/WrapperPolicy/AndroidSymbolPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs create mode 100644 Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-customised.gradle diff --git a/CHANGELOG.md b/CHANGELOG.md index 616c936..c29d8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Android builds on Unity 6 failed in Gradle (`DependencyHandler.module(java.lang.Object)` while evaluating `:launcher`): the package wrote its own `Assets/Plugins/Android/baseProjectTemplate.gradle` and `launcherTemplate.gradle` pinning Android Gradle plugin 7.4.2, which cannot run on Unity 6's Gradle 9. The package no longer creates Gradle templates. It patches the Gradle project Unity generates instead (so nothing in Assets goes stale on Unity upgrades): the Bugsee Gradle plugin is added when the generated project pins AGP 8.6+ (previously: any Unity 6), and on 2021.3 the release build type gets `debugSymbolLevel 'FULL'`. The exact templates earlier package versions wrote are removed on an Android build when Unity's default AGP is another major; any other template pinning a different AGP major is left alone with a warning naming the file and the fix. The sample no longer ships custom Gradle templates. Verified with real IL2CPP arm64 builds on 2021.3.45f1 and 6000.0.84f1; Bugsee Gradle plugin 4.0.8 works with Unity 6's AGP 9.0.0 / Gradle 9.3.1. -- Android native symbols on Unity 6: release builds no longer force `debugSymbolLevel 'FULL'` in the launcher; the level follows Player Settings (Create symbols.zip). Symbol upload reads Unity's `symbols.zip`; a Unity 6 release build with `Create symbols.zip` = Public (as the sample's E2E build uses) yields public symbol tables only (symbols.zip about 18 MB vs about 230 MB with Debugging), so set Debugging for line-level native symbolication. +- Android native symbols on Unity 6: release builds no longer force `debugSymbolLevel 'FULL'` in the launcher; the level follows Player Settings (Create symbols.zip), which Unity 6 defaults to Disabled. Public gives symbol tables only (no file:line); Debugging is about ten times larger. An Android build with a Bugsee app token set now warns when the level is not Debugging, naming what is lost and the size trade-off. Opt in with `Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload` (or `BUGSEE_ANDROID_FULL_SYMBOLS=1` in CI): that build uses Debugging and the setting is restored afterwards. +- Android Gradle cleanup never deletes a template you edited: a file is removed only when it is exactly what an earlier package version generated (or an unedited Unity 2021.3 default base copy), after copying it (and its `.meta`) to `Library/Bugsee/legacy-templates//`. A marker-bearing launcher with your own changes is kept with a warning. A launcher that is left alone and still uses `lintOptions`/`aaptOptions` is reported, and an unreadable AGP version (version catalog, Kotlin DSL) now warns that the Bugsee Gradle plugin was skipped. - Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. - iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. diff --git a/Documentation~/index.md b/Documentation~/index.md index 7719c1f..dd9062c 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -207,7 +207,7 @@ Managed error/crash **grouping** (one signature per event, Unity-primary when st ## Native dependencies -- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1`, declared in the generated Gradle project by `Editor/BugseeAndroidMavenDependencies.cs` (Gradle plugin `4.0.8`, patched into the generated project when it pins AGP 8.6+). Native symbols for upload come from Unity's `symbols.zip` (Player Settings: Create symbols.zip); on Unity 6 the launcher's `debugSymbolLevel` follows Player Settings (choose Debugging for line-level native symbolication; Public gives symbol tables only), on 2021.3 release builds get `FULL`. Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. +- **Android:** Maven `com.bugsee:bugsee-android:7.4.1` + `com.bugsee:bugsee-android-ndk:7.4.1`, declared in the generated Gradle project by `Editor/BugseeAndroidMavenDependencies.cs` (Gradle plugin `4.0.8`, patched into the generated project when it pins AGP 8.6+). Native symbols for upload come from Unity's `symbols.zip` (Player Settings > Publishing Settings > Create symbols.zip). Unity 6 defaults it to Disabled and Public holds symbol tables only; native/IL2CPP file:line symbolication needs Debugging (about ten times larger, mind Google Play's limits). The build warns when a Bugsee app token is set and the level is not Debugging; `Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload` (or `BUGSEE_ANDROID_FULL_SYMBOLS=1`) builds with Debugging and restores the setting. On 2021.3 release builds also get `debugSymbolLevel 'FULL'`. Core publishes no transitives; NDK is a separate artifact and must be declared. Fragment secure-view overloads are typed as `Object`, so Unity JNI does not need `androidx.fragment`. - **iOS:** SPM-only. Xcode resolves [`bugsee/spm`](https://github.com/bugsee/spm) `7.0.0-beta6` (iOS 15+). C bridge: `Plugins/iOS/BugseeUnityBridge.mm` + `BugseeUnityCallbacks.mm`. ### iOS bridge status diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 33e7cdc..5040551 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -120,11 +120,20 @@ static void PatchGeneratedProject(string projectRoot) $"[Bugsee] Launcher applies com.bugsee.android.gradle:{GradlePluginVersion}. " + $"Runtime AAR pin {SdkVersion} with NDK enabled."); } + else if (!GradleTemplateDefaults.TryGetAgpVersion(root, out _, out _)) + { + Debug.LogWarning( + "[Bugsee] Could not read the Android Gradle plugin version from " + rootPath + " (a version catalog alias(...) or a " + + "computed version), so the Bugsee Gradle plugin was NOT applied. bugsee-android + bugsee-android-ndk are still declared. " + + "If the project uses AGP 8.6+, add \"id 'com.bugsee.android.gradle' version '" + GradlePluginVersion + + "' apply false\" to the root plugins block and \"apply plugin: 'com.bugsee.android.gradle'\" to the launcher, " + + "or enable Bugsee > Android > Force Gradle Plugin On Older AGP (unsupported) to apply it regardless of the detected version."); + } else { Debug.Log( - "[Bugsee] Bugsee Gradle plugin not applied: it needs AGP 8.6+ and the generated project pins " + - "an older or unreadable version. bugsee-android + bugsee-android-ndk are still declared in the generated Gradle project."); + "[Bugsee] Bugsee Gradle plugin not applied: it needs AGP 8.6+ and the generated project pins an older version. " + + "bugsee-android + bugsee-android-ndk are still declared in the generated Gradle project."); } } @@ -149,41 +158,85 @@ internal static void RemoveLegacyTemplates() string basePath = Path.Combine(dir, "baseProjectTemplate.gradle"); string launcherPath = Path.Combine(dir, "launcherTemplate.gradle"); string unityBase = UnityTemplatePath("baseProjectTemplate.gradle"); - if (!File.Exists(basePath) || unityBase == null || !File.Exists(unityBase)) + string unityLauncher = UnityTemplatePath("launcherTemplate.gradle"); + if (unityBase == null || !File.Exists(unityBase) || unityLauncher == null || !File.Exists(unityLauncher)) { return; } - string projectBase = File.ReadAllText(basePath); - string unityDefault = File.ReadAllText(unityBase); - if (!GradleTemplateDefaults.PinsDifferentAgpMajor(projectBase, unityDefault)) + string unityDir = Path.GetDirectoryName(unityBase); + string unityBaseText = File.ReadAllText(unityBase); + int unityAgp = GradleTemplateDefaults.PinnedAgpMajor(unityBaseText); + string backupDir = null; + + if (File.Exists(basePath)) { - return; + string text = File.ReadAllText(basePath); + if (GradleTemplateDefaults.PinsDifferentAgpMajor(text, unityBaseText)) + { + int pinned = GradleTemplateDefaults.PinnedAgpMajor(text); + if (GradleTemplateDefaults.IsLegacyPackageBaseTemplate(text)) + { + string origin = GradleTemplateDefaults.HasPackageMarker(text) + ? "written by an earlier Bugsee package version" + : "an unedited copy of Unity 2021.3's default template"; + BackupAndDelete(basePath, ref backupDir, + origin + ", pins Android Gradle plugin " + pinned + ".x but Unity " + Application.unityVersion + + " builds with " + unityAgp + ".x, which fails in Gradle"); + } + else + { + Debug.LogWarning( + "[Bugsee] " + basePath + " pins Android Gradle plugin " + pinned + ".x but Unity " + Application.unityVersion + + " builds with " + unityAgp + ".x, so Gradle will fail with \"DependencyHandler.module(java.lang.Object)\" while " + + "evaluating :launcher. It has edits of its own, so it is left unchanged. Delete it (and launcherTemplate.gradle) to use " + + "Unity's defaults, or re-create them from " + unityDir + " and re-apply your edits."); + } + } } - if (GradleTemplateDefaults.IsLegacyPackageBaseTemplate(projectBase)) + if (File.Exists(launcherPath)) { - DeleteTemplate(basePath, "pinned Android Gradle plugin " + GradleTemplateDefaults.PinnedAgpMajor(projectBase) + - ".x, but Unity " + Application.unityVersion + " builds with " + GradleTemplateDefaults.PinnedAgpMajor(unityDefault) + ".x"); - if (File.Exists(launcherPath) && GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(File.ReadAllText(launcherPath))) + string text = File.ReadAllText(launcherPath); + if (GradleTemplateDefaults.UsesRemovedAndroidDsl(text) && + !GradleTemplateDefaults.UsesRemovedAndroidDsl(File.ReadAllText(unityLauncher))) { - DeleteTemplate(launcherPath, "uses the Gradle DSL of Unity 2021.3, which this Unity's Gradle cannot run"); + if (GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(text)) + { + BackupAndDelete(launcherPath, ref backupDir, + "an unedited launcher template from an earlier Bugsee package version; it uses the Unity 2021.3 Gradle DSL " + + "(lintOptions/aaptOptions), which this Unity's Gradle rejects"); + } + else + { + Debug.LogWarning( + "[Bugsee] " + launcherPath + " uses the Unity 2021.3 Gradle DSL (lintOptions/aaptOptions), which Unity " + + Application.unityVersion + "'s Gradle rejects" + + (GradleTemplateDefaults.HasPackageMarker(text) ? " (it carries Bugsee plugin lines from an earlier package version but also has your edits)" : "") + + ". It is left unchanged. Delete it to use Unity's default, or re-create it from " + unityDir + + " and re-apply your edits (signing, dependencies)."); + } } - - return; } - - Debug.LogWarning( - "[Bugsee] " + basePath + " pins Android Gradle plugin " + GradleTemplateDefaults.PinnedAgpMajor(projectBase) + - ".x but Unity " + Application.unityVersion + " builds with " + GradleTemplateDefaults.PinnedAgpMajor(unityDefault) + - ".x, so Gradle will fail with \"DependencyHandler.module(java.lang.Object)\" while evaluating :launcher. " + - "This is your own template (not one written by the Bugsee package), so it is left unchanged. " + - "Delete it and Assets/Plugins/Android/launcherTemplate.gradle to use Unity's defaults (Player Settings > Publishing Settings), " + - "or re-create them from " + Path.GetDirectoryName(unityBase) + " and re-apply your edits."); } - static void DeleteTemplate(string path, string reason) + /// Copies the file outside Assets (Library/Bugsee/legacy-templates/<timestamp>) before removing it. + static void BackupAndDelete(string path, ref string backupDir, string reason) { + if (backupDir == null) + { + string project = Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)); + backupDir = Path.Combine(project, "Library", "Bugsee", "legacy-templates", DateTime.Now.ToString("yyyyMMdd-HHmmss")); + Directory.CreateDirectory(backupDir); + } + + string backup = Path.Combine(backupDir, Path.GetFileName(path)); + File.Copy(path, backup, true); + if (File.Exists(path + ".meta")) + { + File.Copy(path + ".meta", backup + ".meta", true); + } + string asset = ToAssetPath(path); if (!AssetDatabase.DeleteAsset(asset)) { @@ -191,8 +244,8 @@ static void DeleteTemplate(string path, string reason) } Debug.LogWarning( - "[Bugsee] Removed " + asset + ", written by an earlier Bugsee package version: it " + reason + - ". Unity's default template is used and the Bugsee Gradle plugin is patched into the generated project."); + "[Bugsee] Removed " + asset + ": " + reason + ". Unity's default template is used and the Bugsee Gradle plugin is " + + "patched into the generated project. A copy is in " + backup + "."); } internal static string LauncherMarkedGradlePatch() diff --git a/Editor/BugseeAndroidSymbolLevel.cs b/Editor/BugseeAndroidSymbolLevel.cs new file mode 100644 index 0000000..8b3d3f1 --- /dev/null +++ b/Editor/BugseeAndroidSymbolLevel.cs @@ -0,0 +1,118 @@ +using System; +using System.Reflection; +using Bugsee.WrapperPolicy; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; + +namespace Bugsee.Editor +{ + /// + /// Android native symbols for upload. Unity 6 defaults "Create symbols.zip" to Disabled and Public holds symbol + /// tables only, so file:line symbolication needs Debugging (see ). The build warns + /// when a Bugsee app token is set (symbols are uploaded) and the level is not Debugging; the opt-in + /// (Bugsee > Android > Use Full Native Symbols, or BUGSEE_ANDROID_FULL_SYMBOLS=1) builds with Debugging for + /// that build only and restores the setting afterwards (also after a failed build, on the next editor load). + /// + [InitializeOnLoad] + sealed class BugseeAndroidSymbolLevel : IPreprocessBuildWithReport, IPostprocessBuildWithReport + { + const string PrefFullSymbols = "Bugsee.Android.FullNativeSymbols"; + const string RestoreKey = "Bugsee.Android.RestoreSymbolLevel"; + const string MenuPath = "Bugsee/Android/Use Full Native Symbols (Debugging) For Symbol Upload"; + + // Before the upload (910) reads the zip; the restore is last. + public int callbackOrder => 20; + + static BugseeAndroidSymbolLevel() + { + EditorApplication.delayCall += RestoreIfInterrupted; + } + + static bool UseFullSymbols => + EditorPrefs.GetBool(PrefFullSymbols, false) || + Environment.GetEnvironmentVariable("BUGSEE_ANDROID_FULL_SYMBOLS") == "1"; + + static bool UploadConfigured => + BugseeCliRunner.HasAppToken && Environment.GetEnvironmentVariable("BUGSEE_NO_SYMBOL_UPLOAD") == null; + + // EditorUserBuildSettings.androidCreateSymbols (an AndroidCreateSymbols enum: Disabled/Public/Debugging) lives in + // the Android editor module; reached by reflection so the package compiles for every active build target. + static readonly PropertyInfo CreateSymbols = typeof(EditorUserBuildSettings).GetProperty("androidCreateSymbols"); + + static NativeSymbolLevel Current() + { + string name = CreateSymbols?.GetValue(null)?.ToString(); + switch (name) + { + case "Debugging": return NativeSymbolLevel.Debugging; + case "Public": return NativeSymbolLevel.Public; + default: return NativeSymbolLevel.Disabled; + } + } + + static bool SetCreateSymbols(string name) + { + if (CreateSymbols == null) + { + return false; + } + + CreateSymbols.SetValue(null, Enum.Parse(CreateSymbols.PropertyType, name)); + return true; + } + + [MenuItem(MenuPath)] + static void ToggleFullSymbols() + { + EditorPrefs.SetBool(PrefFullSymbols, !EditorPrefs.GetBool(PrefFullSymbols, false)); + } + + [MenuItem(MenuPath, true)] + static bool ToggleFullSymbolsValidate() + { + Menu.SetChecked(MenuPath, EditorPrefs.GetBool(PrefFullSymbols, false)); + return true; + } + + public void OnPreprocessBuild(BuildReport report) + { + if (report.summary.platform != BuildTarget.Android) + { + return; + } + + NativeSymbolLevel current = Current(); + NativeSymbolDecision decision = AndroidSymbolPolicy.Decide(UseFullSymbols, UploadConfigured, current); + if (decision.Warning != null) + { + Debug.LogWarning(decision.Warning); + } + + if (decision.Change && CreateSymbols != null) + { + SessionState.SetString(RestoreKey, CreateSymbols.GetValue(null).ToString()); + SetCreateSymbols("Debugging"); + Debug.Log("[Bugsee] Building with Create symbols.zip = Debugging for this build (restored afterwards)."); + } + } + + public void OnPostprocessBuild(BuildReport report) + { + RestoreIfInterrupted(); + } + + static void RestoreIfInterrupted() + { + string previous = SessionState.GetString(RestoreKey, ""); + if (previous.Length == 0) + { + return; + } + + SessionState.EraseString(RestoreKey); + SetCreateSymbols(previous); + } + } +} diff --git a/Editor/BugseeAndroidSymbolLevel.cs.meta b/Editor/BugseeAndroidSymbolLevel.cs.meta new file mode 100644 index 0000000..86417ca --- /dev/null +++ b/Editor/BugseeAndroidSymbolLevel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 950854811f21495eb57d3d07379a9ab0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs b/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs new file mode 100644 index 0000000..b8d352e --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs @@ -0,0 +1,63 @@ +namespace Bugsee.WrapperPolicy +{ + /// Unity's Android "Create symbols.zip" level (EditorUserBuildSettings.androidCreateSymbols). + public enum NativeSymbolLevel + { + Disabled, + Public, + Debugging, + } + + /// What an Android build should do about native symbols, given symbol upload and the opt-in. + public readonly struct NativeSymbolDecision + { + public NativeSymbolDecision(bool change, string warning) + { + Change = change; + Warning = warning; + } + + /// Build with for this build (and restore afterwards). + public bool Change { get; } + + /// A build-time warning, or null. + public string Warning { get; } + } + + /// + /// Native file:line symbolication needs DWARF in symbols.zip, which only the Debugging level produces (Public gives + /// symbol tables, Disabled nothing). Debugging is about ten times larger, so it is never forced silently: the build + /// warns when symbols are uploaded without it, and (menu or + /// BUGSEE_ANDROID_FULL_SYMBOLS=1) opts in. + /// + public static class AndroidSymbolPolicy + { + public static NativeSymbolDecision Decide(bool useFullSymbols, bool uploadConfigured, NativeSymbolLevel current) + { + if (current == NativeSymbolLevel.Debugging) + { + return new NativeSymbolDecision(false, null); + } + + if (useFullSymbols) + { + return new NativeSymbolDecision(true, null); + } + + if (!uploadConfigured) + { + return new NativeSymbolDecision(false, null); + } + + const string tradeOff = + " Set Player Settings > Publishing Settings > Create symbols.zip to Debugging, or enable the menu item " + + "Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload (or BUGSEE_ANDROID_FULL_SYMBOLS=1). " + + "Debugging symbols.zip is about ten times larger than Public and counts against Google Play's size limits for the symbols file."; + return new NativeSymbolDecision( + false, + current == NativeSymbolLevel.Disabled + ? "[Bugsee] Create symbols.zip is Disabled, so there is no native symbols file to upload and native/IL2CPP crash frames get no function names or file:line symbolication." + tradeOff + : "[Bugsee] Create symbols.zip is Public: it holds symbol tables only, so native/IL2CPP crash frames get function names but no file:line symbolication." + tradeOff); + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs.meta b/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs.meta new file mode 100644 index 0000000..ee9c2be --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6482d6cf4b847d2be15de702eed48b1 +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 14a2e5b..6d01491 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -151,11 +151,94 @@ public static bool IsLegacyPackageBaseTemplate(string text) return kept.Count == LegacyBaseBody.Length && System.Linq.Enumerable.SequenceEqual(kept, LegacyBaseBody); } + // The launcherTemplate.gradle earlier package versions wrote (Unity 2021.3's Groovy layout plus the release + // debugSymbolLevel 'FULL' block), without the marked Bugsee plugin lines. + static readonly string[] LegacyLauncherBody = + { + "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**", + "ndk {", + "debugSymbolLevel 'FULL'", + "}", + "}", + "}**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**" + }; + + /// True when carries the package's bugsee:gradle-plugin marker. + public static bool HasPackageMarker(string text) + { + return text != null && text.IndexOf(PluginMarker, StringComparison.Ordinal) >= 0; + } + + /// True for the Unity 2021.3-era Gradle DSL (lintOptions/aaptOptions) that Gradle 9 rejects. + public static bool UsesRemovedAndroidDsl(string text) + { + return text != null && + (text.IndexOf("lintOptions", StringComparison.Ordinal) >= 0 || + text.IndexOf("aaptOptions", StringComparison.Ordinal) >= 0); + } + /// - /// True for a launcher template an earlier package version generated (Unity 2021.3's Groovy layout, which - /// Unity 6's Gradle cannot run): it carries the package's bugsee:gradle-plugin marker, or the - /// release debugSymbolLevel 'FULL' block the package added to Unity 2021.3's template, and still uses the - /// removed lintOptions/aaptOptions DSL. + /// True only for a launcher template an earlier package version generated, line for line apart from + /// whitespace and the marked Bugsee plugin block. A launcher with any other change (signing, dependencies) + /// is the project's own and must never be removed, even if it carries the marker. /// public static bool IsLegacyPackageLauncherTemplate(string text) { @@ -164,12 +247,21 @@ public static bool IsLegacyPackageLauncherTemplate(string text) return false; } - bool oldDsl = text.IndexOf("lintOptions", StringComparison.Ordinal) >= 0 || - text.IndexOf("aaptOptions", StringComparison.Ordinal) >= 0; - bool ours = text.IndexOf(PluginMarker, StringComparison.Ordinal) >= 0 || - (text.IndexOf("debugSymbolLevel 'FULL'", StringComparison.Ordinal) >= 0 && - text.IndexOf("**LAUNCHER_SOURCE_BUILD_SETUP**", StringComparison.Ordinal) >= 0); - return oldDsl && ours; + var lines = new System.Collections.Generic.List(NormalizedLines(text)); + int marker = lines.FindIndex(l => l.IndexOf(PluginMarker, StringComparison.Ordinal) >= 0); + if (marker >= 0) + { + // apply plugin line, blank, "bugsee {" ... "} // bugsee:gradle-ndk" + int end = lines.FindIndex(marker, l => l.IndexOf("bugsee:gradle-ndk", StringComparison.Ordinal) >= 0); + if (end < 0) + { + return false; + } + + lines.RemoveRange(marker, end - marker + 1); + } + + return lines.Count == LegacyLauncherBody.Length && System.Linq.Enumerable.SequenceEqual(lines, LegacyLauncherBody); } /// diff --git a/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs b/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs new file mode 100644 index 0000000..ef92aae --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs @@ -0,0 +1,52 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidSymbolPolicyTests + { + [TestCase(NativeSymbolLevel.Disabled, "Disabled")] + [TestCase(NativeSymbolLevel.Public, "Public")] + public void Upload_without_debugging_symbols_warns_with_what_is_lost_and_the_trade_off(NativeSymbolLevel level, string named) + { + var d = AndroidSymbolPolicy.Decide(useFullSymbols: false, uploadConfigured: true, current: level); + + Assert.That(d.Change, Is.False); + Assert.That(d.Warning, Does.Contain("Create symbols.zip is " + named)); + Assert.That(d.Warning, Does.Contain("file:line")); + Assert.That(d.Warning, Does.Contain("ten times larger")); + Assert.That(d.Warning, Does.Contain("BUGSEE_ANDROID_FULL_SYMBOLS=1")); + } + + [TestCase(NativeSymbolLevel.Disabled)] + [TestCase(NativeSymbolLevel.Public)] + public void No_upload_configured_means_no_warning_and_no_change(NativeSymbolLevel level) + { + var d = AndroidSymbolPolicy.Decide(false, false, level); + Assert.That(d.Change, Is.False); + Assert.That(d.Warning, Is.Null); + } + + [TestCase(NativeSymbolLevel.Disabled)] + [TestCase(NativeSymbolLevel.Public)] + public void Opt_in_switches_to_debugging_without_warning(NativeSymbolLevel level) + { + foreach (bool upload in new[] { true, false }) + { + var d = AndroidSymbolPolicy.Decide(true, upload, level); + Assert.That(d.Change, Is.True); + Assert.That(d.Warning, Is.Null); + } + } + + [Test] + public void Debugging_is_left_alone() + { + foreach (bool opt in new[] { true, false }) + { + var d = AndroidSymbolPolicy.Decide(opt, true, NativeSymbolLevel.Debugging); + Assert.That(d.Change, Is.False); + Assert.That(d.Warning, Is.Null); + } + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-customised.gradle b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-customised.gradle new file mode 100644 index 0000000..abd7ad4 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/LegacyPackage/launcher-customised.gradle @@ -0,0 +1,74 @@ +apply plugin: 'com.android.application' +apply plugin: 'com.bugsee.android.gradle' // bugsee:gradle-plugin + +bugsee { + // App token is supplied at runtime via Bugsee.Launch. + ndk { + enabled = true + } +} // bugsee:gradle-ndk + +dependencies { + implementation project(':unityLibrary') + implementation 'com.example:mycompany-sdk:1.2.3' +} + +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** + ndk { + debugSymbolLevel 'FULL' + } + } + }**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/GradleTemplateDefaultsTests.cs b/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs index 06a004e..35291a5 100644 --- a/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs +++ b/Tests~/WrapperPolicy/GradleTemplateDefaultsTests.cs @@ -84,6 +84,32 @@ public void Launcher_templates_written_by_earlier_package_versions_are_recognise Assert.That(GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(Legacy(name)), Is.True); } + [Test] + public void A_launcher_with_the_marker_but_its_own_edits_is_never_recognised_as_ours() + { + string customised = Legacy("launcher-customised.gradle"); + Assert.That(GradleTemplateDefaults.HasPackageMarker(customised), Is.True); + Assert.That(GradleTemplateDefaults.UsesRemovedAndroidDsl(customised), Is.True); + Assert.That(GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(customised), Is.False); + Assert.That(GradleTemplateDefaults.IsLegacyPackageLauncherTemplate(Legacy("launcher-with-plugin.gradle") + "// edit\n"), Is.False); + } + + [Test] + public void An_unedited_unity_2021_base_copy_has_no_package_marker() + { + string copy = Fixture("2021.3.45f1", "baseProjectTemplate.gradle"); + Assert.That(GradleTemplateDefaults.IsLegacyPackageBaseTemplate(copy), Is.True); + Assert.That(GradleTemplateDefaults.HasPackageMarker(copy), Is.False); + Assert.That(GradleTemplateDefaults.HasPackageMarker(Legacy("base-with-plugin.gradle")), Is.True); + } + + [Test] + public void Unity_6_templates_do_not_use_the_removed_dsl() + { + Assert.That(GradleTemplateDefaults.UsesRemovedAndroidDsl(Fixture("6000.0.84f1", "launcherTemplate.gradle")), Is.False); + Assert.That(GradleTemplateDefaults.UsesRemovedAndroidDsl(Fixture("2021.3.45f1", "launcherTemplate.gradle")), Is.True); + } + [Test] public void Unity_default_launcher_templates_are_not_recognised_as_ours() { @@ -118,8 +144,10 @@ public void Package_editor_code_does_not_pin_an_agp_version_or_write_a_template_ Assert.That(editor, Does.Not.Contain("com.android.application' version")); Assert.That(editor, Does.Not.Contain("StringBuilder")); - Assert.That(editor, Does.Not.Contain("File.Copy")); Assert.That(editor, Does.Contain("IPostGenerateGradleAndroidProject")); + Assert.That(editor, Does.Contain("legacy-templates")); + Assert.That(editor.IndexOf("File.Copy", System.StringComparison.Ordinal), + Is.LessThan(editor.IndexOf("AssetDatabase.DeleteAsset", System.StringComparison.Ordinal))); } } } From 47bb57b19bdd740cc1efea75d1044eb539bb387f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 17:20:21 +0500 Subject: [PATCH 142/157] Use Unity 6's DebugSymbols settings for the symbol opt-in; make the restore failure-safe. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 6000.x symbols.zip level/format are UnityEditor.Android.UserBuildSettings.DebugSymbols (androidCreateSymbols is obsolete there); read and set both, Zip format included, with the 2021.3 property as fallback. The pending restore lives in Library/Bugsee, is ignored by non-Android builds and an in-progress build, and applies only while the settings still hold what the opt-in set. Reword the obsolete custom-template checklist item. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 2 +- Editor/BugseeAndroidSymbolLevel.cs | 177 ++++++++++++++---- Runtime/WrapperPolicy/AndroidSymbolPolicy.cs | 40 +++- .../WrapperPolicy/AndroidSymbolPolicyTests.cs | 27 +++ 5 files changed, 208 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c29d8b6..6aca7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Android builds on Unity 6 failed in Gradle (`DependencyHandler.module(java.lang.Object)` while evaluating `:launcher`): the package wrote its own `Assets/Plugins/Android/baseProjectTemplate.gradle` and `launcherTemplate.gradle` pinning Android Gradle plugin 7.4.2, which cannot run on Unity 6's Gradle 9. The package no longer creates Gradle templates. It patches the Gradle project Unity generates instead (so nothing in Assets goes stale on Unity upgrades): the Bugsee Gradle plugin is added when the generated project pins AGP 8.6+ (previously: any Unity 6), and on 2021.3 the release build type gets `debugSymbolLevel 'FULL'`. The exact templates earlier package versions wrote are removed on an Android build when Unity's default AGP is another major; any other template pinning a different AGP major is left alone with a warning naming the file and the fix. The sample no longer ships custom Gradle templates. Verified with real IL2CPP arm64 builds on 2021.3.45f1 and 6000.0.84f1; Bugsee Gradle plugin 4.0.8 works with Unity 6's AGP 9.0.0 / Gradle 9.3.1. -- Android native symbols on Unity 6: release builds no longer force `debugSymbolLevel 'FULL'` in the launcher; the level follows Player Settings (Create symbols.zip), which Unity 6 defaults to Disabled. Public gives symbol tables only (no file:line); Debugging is about ten times larger. An Android build with a Bugsee app token set now warns when the level is not Debugging, naming what is lost and the size trade-off. Opt in with `Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload` (or `BUGSEE_ANDROID_FULL_SYMBOLS=1` in CI): that build uses Debugging and the setting is restored afterwards. +- Android native symbols on Unity 6: release builds no longer force `debugSymbolLevel 'FULL'` in the launcher; the level follows Player Settings (Create symbols.zip), which Unity 6 defaults to Disabled. Public gives symbol tables only (no file:line); Debugging is about ten times larger. An Android build with a Bugsee app token set now warns when the level is not Debugging, naming what is lost and the size trade-off. On Unity 6 the setting is `UnityEditor.Android.UserBuildSettings.DebugSymbols` (level None/SymbolTable/Full plus a Zip format; the 2021.3 `androidCreateSymbols` is obsolete there), and the opt-in also enables the Zip format. Opt in with `Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload` (or `BUGSEE_ANDROID_FULL_SYMBOLS=1` in CI): that build uses Debugging and the setting is restored afterwards (the saved state lives in `Library/Bugsee`, survives an editor exit after an interrupted build, and is restored only while the settings still hold what the opt-in set; a non-Android build never touches it). - Android Gradle cleanup never deletes a template you edited: a file is removed only when it is exactly what an earlier package version generated (or an unedited Unity 2021.3 default base copy), after copying it (and its `.meta`) to `Library/Bugsee/legacy-templates//`. A marker-bearing launcher with your own changes is kept with a warning. A launcher that is left alone and still uses `lintOptions`/`aaptOptions` is reported, and an unreadable AGP version (version catalog, Kotlin DSL) now warns that the Bugsee Gradle plugin was skipped. - Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. - Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates only; Unity 2022 and Unity 6 layouts are reconstructed (Groovy, same shape), not built. diff --git a/Documentation~/index.md b/Documentation~/index.md index dd9062c..2c0bdba 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -146,7 +146,7 @@ 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. Custom Base / Launcher Gradle templates (Bugsee injects `ndk { enabled = true }` on Unity 6+) +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 **iOS** diff --git a/Editor/BugseeAndroidSymbolLevel.cs b/Editor/BugseeAndroidSymbolLevel.cs index 8b3d3f1..bc85c00 100644 --- a/Editor/BugseeAndroidSymbolLevel.cs +++ b/Editor/BugseeAndroidSymbolLevel.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Reflection; using Bugsee.WrapperPolicy; using UnityEditor; @@ -9,27 +10,70 @@ namespace Bugsee.Editor { /// - /// Android native symbols for upload. Unity 6 defaults "Create symbols.zip" to Disabled and Public holds symbol - /// tables only, so file:line symbolication needs Debugging (see ). The build warns - /// when a Bugsee app token is set (symbols are uploaded) and the level is not Debugging; the opt-in - /// (Bugsee > Android > Use Full Native Symbols, or BUGSEE_ANDROID_FULL_SYMBOLS=1) builds with Debugging for - /// that build only and restores the setting afterwards (also after a failed build, on the next editor load). + /// Android native symbols for upload. Unity 6 defaults the debug symbol level to None (symbols.zip off) and + /// SymbolTable holds symbol tables only, so file:line symbolication needs Full (Debugging) with a Zip format + /// (see ). The build warns when a Bugsee app token is set and that is not the + /// case; the opt-in (Bugsee > Android > Use Full Native Symbols, or BUGSEE_ANDROID_FULL_SYMBOLS=1) + /// builds with it and restores the settings afterwards. + /// + /// The setting lives in UnityEditor.Android.UserBuildSettings.DebugSymbols.level/format on Unity 6 (checked + /// on 6000.0.84f1: EditorUserBuildSettings.androidCreateSymbols is obsolete there and only forwards to it) and in + /// EditorUserBuildSettings.androidCreateSymbols on Unity 2021.3. Both are in the Android editor module, so they + /// are reached by reflection and the package compiles for every active build target. + /// + /// The saved state is kept in Library/Bugsee (it survives an editor exit after an interrupted build) and is restored + /// only while the settings still hold what the opt-in applied, never by an unrelated (non-Android) build. /// [InitializeOnLoad] sealed class BugseeAndroidSymbolLevel : IPreprocessBuildWithReport, IPostprocessBuildWithReport { const string PrefFullSymbols = "Bugsee.Android.FullNativeSymbols"; - const string RestoreKey = "Bugsee.Android.RestoreSymbolLevel"; + const string InProgressKey = "Bugsee.Android.SymbolBuildInProgress"; const string MenuPath = "Bugsee/Android/Use Full Native Symbols (Debugging) For Symbol Upload"; - // Before the upload (910) reads the zip; the restore is last. + // Level names per API: Unity 6 None/SymbolTable/Full, Unity 2021.3 Disabled/Public/Debugging. + static readonly PropertyInfo NewLevel; + static readonly PropertyInfo NewFormat; + static readonly PropertyInfo OldCreateSymbols = typeof(EditorUserBuildSettings).GetProperty("androidCreateSymbols"); + public int callbackOrder => 20; static BugseeAndroidSymbolLevel() { - EditorApplication.delayCall += RestoreIfInterrupted; + Type debugSymbols = null; + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + debugSymbols = assembly.GetType("UnityEditor.Android.UserBuildSettings+DebugSymbols"); + } + catch (Exception) + { + } + + if (debugSymbols != null) + { + break; + } + } + + NewLevel = debugSymbols?.GetProperty("level"); + NewFormat = debugSymbols?.GetProperty("format"); + EditorApplication.delayCall += () => ResolvePending(androidBuildOrEditorLoad: true, inProgress: SessionState.GetBool(InProgressKey, false)); + } + + [Serializable] + sealed class PendingRestore + { + public string previousLevel; + public int previousFormat; + public string appliedLevel; + public int appliedFormat; } + static string PendingPath => + Path.Combine(Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)), "Library", "Bugsee", "native-symbols-restore.json"); + static bool UseFullSymbols => EditorPrefs.GetBool(PrefFullSymbols, false) || Environment.GetEnvironmentVariable("BUGSEE_ANDROID_FULL_SYMBOLS") == "1"; @@ -37,32 +81,53 @@ static BugseeAndroidSymbolLevel() static bool UploadConfigured => BugseeCliRunner.HasAppToken && Environment.GetEnvironmentVariable("BUGSEE_NO_SYMBOL_UPLOAD") == null; - // EditorUserBuildSettings.androidCreateSymbols (an AndroidCreateSymbols enum: Disabled/Public/Debugging) lives in - // the Android editor module; reached by reflection so the package compiles for every active build target. - static readonly PropertyInfo CreateSymbols = typeof(EditorUserBuildSettings).GetProperty("androidCreateSymbols"); + static bool Available => NewLevel != null || OldCreateSymbols != null; - static NativeSymbolLevel Current() + static string ReadLevel() { - string name = CreateSymbols?.GetValue(null)?.ToString(); - switch (name) + return (NewLevel != null ? NewLevel.GetValue(null) : OldCreateSymbols?.GetValue(null))?.ToString(); + } + + static int ReadFormat() + { + return NewFormat != null ? Convert.ToInt32(NewFormat.GetValue(null)) : -1; + } + + static int ZipFlag() + { + return NewFormat != null ? Convert.ToInt32(Enum.Parse(NewFormat.PropertyType, "Zip")) : 0; + } + + static void Write(string level, int format) + { + if (NewLevel != null) { - case "Debugging": return NativeSymbolLevel.Debugging; - case "Public": return NativeSymbolLevel.Public; - default: return NativeSymbolLevel.Disabled; + NewLevel.SetValue(null, Enum.Parse(NewLevel.PropertyType, level)); + if (NewFormat != null && format >= 0) + { + NewFormat.SetValue(null, Enum.ToObject(NewFormat.PropertyType, format)); + } + } + else if (OldCreateSymbols != null) + { + OldCreateSymbols.SetValue(null, Enum.Parse(OldCreateSymbols.PropertyType, level)); } } - static bool SetCreateSymbols(string name) + static NativeSymbolLevel ToLevel(string name) { - if (CreateSymbols == null) + switch (name) { - return false; + case "Full": + case "Debugging": return NativeSymbolLevel.Debugging; + case "SymbolTable": + case "Public": return NativeSymbolLevel.Public; + default: return NativeSymbolLevel.Disabled; } - - CreateSymbols.SetValue(null, Enum.Parse(CreateSymbols.PropertyType, name)); - return true; } + static string FullName => NewLevel != null ? "Full" : "Debugging"; + [MenuItem(MenuPath)] static void ToggleFullSymbols() { @@ -78,41 +143,81 @@ static bool ToggleFullSymbolsValidate() public void OnPreprocessBuild(BuildReport report) { - if (report.summary.platform != BuildTarget.Android) + if (report.summary.platform != BuildTarget.Android || !Available) { return; } - NativeSymbolLevel current = Current(); - NativeSymbolDecision decision = AndroidSymbolPolicy.Decide(UseFullSymbols, UploadConfigured, current); + // A restore left by an interrupted build is settled first, from what the settings hold now. + ResolvePending(androidBuildOrEditorLoad: true, inProgress: false); + + string level = ReadLevel(); + int format = ReadFormat(); + bool zip = NewFormat == null || (format & ZipFlag()) != 0; + NativeSymbolDecision decision = AndroidSymbolPolicy.Decide(UseFullSymbols, UploadConfigured, ToLevel(level), zip); if (decision.Warning != null) { Debug.LogWarning(decision.Warning); } - if (decision.Change && CreateSymbols != null) + if (!decision.Change) { - SessionState.SetString(RestoreKey, CreateSymbols.GetValue(null).ToString()); - SetCreateSymbols("Debugging"); - Debug.Log("[Bugsee] Building with Create symbols.zip = Debugging for this build (restored afterwards)."); + return; } + + int appliedFormat = format >= 0 ? format | ZipFlag() : -1; + var pending = new PendingRestore { previousLevel = level, previousFormat = format, appliedLevel = FullName, appliedFormat = appliedFormat }; + Directory.CreateDirectory(Path.GetDirectoryName(PendingPath)); + File.WriteAllText(PendingPath, JsonUtility.ToJson(pending)); + SessionState.SetBool(InProgressKey, true); + Write(FullName, appliedFormat); + Debug.Log("[Bugsee] Building with native debug symbols = " + FullName + " and a symbols zip for this build (restored afterwards)."); } public void OnPostprocessBuild(BuildReport report) { - RestoreIfInterrupted(); + if (report.summary.platform != BuildTarget.Android) + { + return; + } + + SessionState.EraseBool(InProgressKey); + ResolvePending(androidBuildOrEditorLoad: true, inProgress: false); } - static void RestoreIfInterrupted() + static void ResolvePending(bool androidBuildOrEditorLoad, bool inProgress) { - string previous = SessionState.GetString(RestoreKey, ""); - if (previous.Length == 0) + if (!File.Exists(PendingPath) || !Available) + { + return; + } + + PendingRestore pending; + try + { + pending = JsonUtility.FromJson(File.ReadAllText(PendingPath)); + } + catch (Exception) { + File.Delete(PendingPath); return; } - SessionState.EraseString(RestoreKey); - SetCreateSymbols(previous); + bool unchanged = ReadLevel() == pending.appliedLevel && ReadFormat() == pending.appliedFormat; + switch (AndroidSymbolPolicy.Restore(androidBuildOrEditorLoad, inProgress, unchanged)) + { + case SymbolRestoreAction.Skip: + return; + case SymbolRestoreAction.Restore: + Write(pending.previousLevel, pending.previousFormat); + Debug.Log("[Bugsee] Restored the native debug symbols setting to " + pending.previousLevel + "."); + break; + default: + Debug.Log("[Bugsee] Native debug symbols setting was changed since the Bugsee opt-in; keeping your setting."); + break; + } + + File.Delete(PendingPath); } } } diff --git a/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs b/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs index b8d352e..1ecb31b 100644 --- a/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs +++ b/Runtime/WrapperPolicy/AndroidSymbolPolicy.cs @@ -24,6 +24,18 @@ public NativeSymbolDecision(bool change, string warning) public string Warning { get; } } + public enum SymbolRestoreAction + { + /// Leave everything as it is. + Skip, + + /// Put the settings back to what they were before the opt-in changed them. + Restore, + + /// The settings no longer hold what the opt-in applied (the user changed them): keep them, forget the saved state. + Discard, + } + /// /// Native file:line symbolication needs DWARF in symbols.zip, which only the Debugging level produces (Public gives /// symbol tables, Disabled nothing). Debugging is about ten times larger, so it is never forced silently: the build @@ -32,9 +44,10 @@ public NativeSymbolDecision(bool change, string warning) /// public static class AndroidSymbolPolicy { - public static NativeSymbolDecision Decide(bool useFullSymbols, bool uploadConfigured, NativeSymbolLevel current) + /// The symbols zip is produced (Unity 6: the format includes Zip). Unity 2021.3 has no format option. + public static NativeSymbolDecision Decide(bool useFullSymbols, bool uploadConfigured, NativeSymbolLevel current, bool zipEnabled = true) { - if (current == NativeSymbolLevel.Debugging) + if (current == NativeSymbolLevel.Debugging && zipEnabled) { return new NativeSymbolDecision(false, null); } @@ -44,6 +57,15 @@ public static NativeSymbolDecision Decide(bool useFullSymbols, bool uploadConfig return new NativeSymbolDecision(true, null); } + if (current == NativeSymbolLevel.Debugging) + { + return uploadConfigured + ? new NativeSymbolDecision(false, + "[Bugsee] The symbols zip is not produced (Player Settings > Publishing Settings: the debug symbols format has no Zip), so there is no native symbols file to upload " + + "and native/IL2CPP crashes will not be symbolicated. Enable Zip, or the menu item Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload.") + : new NativeSymbolDecision(false, null); + } + if (!uploadConfigured) { return new NativeSymbolDecision(false, null); @@ -59,5 +81,19 @@ public static NativeSymbolDecision Decide(bool useFullSymbols, bool uploadConfig ? "[Bugsee] Create symbols.zip is Disabled, so there is no native symbols file to upload and native/IL2CPP crash frames get no function names or file:line symbolication." + tradeOff : "[Bugsee] Create symbols.zip is Public: it holds symbol tables only, so native/IL2CPP crash frames get function names but no file:line symbolication." + tradeOff); } + + /// + /// A restore pending from an earlier (possibly interrupted) opt-in. Unrelated builds never touch it; it is + /// restored only while the settings still hold what the opt-in applied, so a level the user chose since is kept. + /// + public static SymbolRestoreAction Restore(bool androidBuildOrEditorLoad, bool androidBuildInProgress, bool currentEqualsApplied) + { + if (!androidBuildOrEditorLoad || androidBuildInProgress) + { + return SymbolRestoreAction.Skip; + } + + return currentEqualsApplied ? SymbolRestoreAction.Restore : SymbolRestoreAction.Discard; + } } } diff --git a/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs b/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs index ef92aae..867c230 100644 --- a/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs +++ b/Tests~/WrapperPolicy/AndroidSymbolPolicyTests.cs @@ -48,5 +48,32 @@ public void Debugging_is_left_alone() Assert.That(d.Warning, Is.Null); } } + + [Test] + public void Debugging_without_a_zip_warns_on_upload_and_the_opt_in_enables_the_zip() + { + var warn = AndroidSymbolPolicy.Decide(false, true, NativeSymbolLevel.Debugging, zipEnabled: false); + Assert.That(warn.Change, Is.False); + Assert.That(warn.Warning, Does.Contain("symbols zip is not produced")); + Assert.That(AndroidSymbolPolicy.Decide(false, false, NativeSymbolLevel.Debugging, zipEnabled: false).Warning, Is.Null); + + var optIn = AndroidSymbolPolicy.Decide(true, true, NativeSymbolLevel.Debugging, zipEnabled: false); + Assert.That(optIn.Change, Is.True); + Assert.That(optIn.Warning, Is.Null); + } + + [Test] + public void An_unrelated_or_running_build_never_touches_a_pending_restore() + { + Assert.That(AndroidSymbolPolicy.Restore(false, false, true), Is.EqualTo(SymbolRestoreAction.Skip)); + Assert.That(AndroidSymbolPolicy.Restore(true, true, true), Is.EqualTo(SymbolRestoreAction.Skip)); + } + + [Test] + public void A_pending_restore_applies_only_while_the_settings_still_hold_what_the_opt_in_set() + { + Assert.That(AndroidSymbolPolicy.Restore(true, false, true), Is.EqualTo(SymbolRestoreAction.Restore)); + Assert.That(AndroidSymbolPolicy.Restore(true, false, false), Is.EqualTo(SymbolRestoreAction.Discard)); + } } } From be92b30f9eac05c48f7191c71d7ddf148d3e907a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 18:55:26 +0500 Subject: [PATCH 143/157] Docs: view position for redaction is read per capture, not once a second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Documentation~/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation~/index.md b/Documentation~/index.md index 787cc0b..31224c6 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -252,7 +252,7 @@ What to know before using it: - Needs `VideoMode.DirectBuffers`. With any other mode the SDK ignores the frames; with `DirectBuffers` and no `Enable()` nothing pushes frames, so there is no real video. - It records Unity's final composite, including screen-space overlay UI. Native Android views (WebViews, dialogs, system UI) are not in the video. -- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (top-left origin). They are mapped relative to where Unity's view sits in the window (its position and size, re-read about once a second, so a status bar, cutout or multi-window offset is accounted for) and scaled to the frame; if that position cannot be read, or the surface found does not have the aspect ratio of Unity's screen (a plugin's SurfaceView, or a view caught mid-rotation), the whole frame is blanked while a rectangle is registered. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. +- Redaction: the SDK does not mask frames pushed this way: no secure rectangles, no secure views, and no pixelation. The pusher blanks rectangles registered with `Bugsee.AddSecureRectangle`, read as native window pixels (top-left origin). They are mapped relative to where Unity's view sits in the window (its position and size, read again when each capture starts and when its pixels arrive, so a status bar, cutout or multi-window offset is accounted for) and scaled to the frame; if that position cannot be read, or the surface found does not have the aspect ratio of Unity's screen (a plugin's SurfaceView, or a view caught mid-rotation), the whole frame is blanked while a rectangle is registered. A rectangle is masked if it was registered when the capture started or when the pixels arrived. Secure native views are not known to it. Blackout is honoured by the SDK. - Row order of the readback was measured for Vulkan (Galaxy Tab S5e, Android 11, Adreno 615, Unity 6000.0.84f1: TopDown) and for OpenGLES3 (Android 16 arm64 emulator, swiftshader, Unity 6000.0.84f1: BottomUp, with a red-over-blue pattern; no GLES3 device was measured). For any other graphics API (OpenGLES2) it is unverified: the launch falls back to the default capture (above), and a capturer started directly with `Enable()` fails closed (every frame fully black while a secure rectangle is registered). Check the order on your device with `BugseeFrameCapturerOptions.LogOrientationProbe` (the sample's `bugsee_frame_pattern=1` shows a red-over-blue pattern; red in the first logged row means TopDown) and then set `BugseeFrameCapturerOptions.RowOrder`, which declares it verified. - Readback is refused when the device lacks `SystemInfo.supportsAsyncGPUReadback`, or on Vulkan with Unity older than 2021.3.18 (not reachable on supported versions). After 5 failed readbacks in a row it turns itself off. - The SDK caps pushed frames by `CaptureVideoFrameRate` as well; a `TargetFps` above that is dropped by the SDK. From 8f47261183395c6deefc0f77285da5ffc8f68299 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 19:02:39 +0500 Subject: [PATCH 144/157] Add Unity 2022.3.62f3 generated Gradle fixtures (APK build and Export Project) to the layout tests. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files come from real IL2CPP builds of the sample on 2022.3.62f3 (AGP 7.4.2, Gradle 7.5.1), minus Bugsee's own edits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../gradle-wrapper.properties | 1 + .../gradle.properties | 8 ++ .../launcher.build.gradle | 71 ++++++++++++ .../root.build.gradle | 13 +++ .../Unity2022.3.62f3-default/settings.gradle | 23 ++++ .../unityLibrary.build.gradle | 49 ++++++++ .../gradle-wrapper.properties | 1 + .../Unity2022.3.62f3-export/gradle.properties | 8 ++ .../launcher.build.gradle | 71 ++++++++++++ .../Unity2022.3.62f3-export/root.build.gradle | 13 +++ .../Unity2022.3.62f3-export/settings.gradle | 23 ++++ .../unityLibrary.build.gradle | 108 ++++++++++++++++++ .../UnityGeneratedLayoutTests.cs | 37 +++++- 13 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties new file mode 100644 index 0000000..4dad05f --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties new file mode 100644 index 0000000..6092d50 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4096M +org.gradle.parallel=true +unityStreamingAssets= +unityTemplateVersion=7 +android.useAndroidX=true +android.enableJetifier=true +org.gradle.welcome=never +android.aapt2FromMavenOverride=/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/SDK/build-tools/34.0.0/aapt2 \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle new file mode 100644 index 0000000..9ed7e46 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle @@ -0,0 +1,71 @@ +apply plugin: 'com.android.application' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "com.bugsee.minigame" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + applicationId 'com.bugsee.minigame' + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + jniDebuggable true + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + } + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } + + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.gradle new file mode 100644 index 0000000..68e3528 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.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 + +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle new file mode 100644 index 0000000..86858f1 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + + gradlePluginPortal() + google() + mavenCentral() + } +} + +include ':launcher', ':unityLibrary' + + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + + google() + mavenCentral() + flatDir { + dirs "${project(':unityLibrary').projectDir}/libs" + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle new file mode 100644 index 0000000..6add842 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.library' + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + +} + +android { + namespace "com.unity3d.player" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + consumerProguardFiles 'proguard-unity.txt' + } + + lintOptions { + abortOnError false + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } +} + + + diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties new file mode 100644 index 0000000..4dad05f --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties new file mode 100644 index 0000000..6092d50 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4096M +org.gradle.parallel=true +unityStreamingAssets= +unityTemplateVersion=7 +android.useAndroidX=true +android.enableJetifier=true +org.gradle.welcome=never +android.aapt2FromMavenOverride=/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/SDK/build-tools/34.0.0/aapt2 \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle new file mode 100644 index 0000000..9ed7e46 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle @@ -0,0 +1,71 @@ +apply plugin: 'com.android.application' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "com.bugsee.minigame" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + applicationId 'com.bugsee.minigame' + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + jniDebuggable true + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + } + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } + + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.gradle new file mode 100644 index 0000000..68e3528 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.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 + +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle new file mode 100644 index 0000000..86858f1 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + + gradlePluginPortal() + google() + mavenCentral() + } +} + +include ':launcher', ':unityLibrary' + + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + + google() + mavenCentral() + flatDir { + dirs "${project(':unityLibrary').projectDir}/libs" + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle new file mode 100644 index 0000000..098947c --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle @@ -0,0 +1,108 @@ +apply plugin: 'com.android.library' + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + +} + +android { + namespace "com.unity3d.player" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + consumerProguardFiles 'proguard-unity.txt' + } + + lintOptions { + abortOnError false + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } +} + +def getSdkDir() { + Properties local = new Properties() + local.load(new FileInputStream("${rootDir}/local.properties")) + return local.getProperty('sdk.dir') +} + +def BuildIl2Cpp(String workingDir, String configuration, String architecture, String abi, String[] staticLibraries) { + def commandLineArgs = [] + commandLineArgs.add("--compile-cpp") + commandLineArgs.add("--platform=Android") + commandLineArgs.add("--architecture=" + architecture) + commandLineArgs.add("--outputpath=" + workingDir + "/src/main/jniLibs/" + abi + "/libil2cpp.so") + commandLineArgs.add("--baselib-directory=" + workingDir + "/src/main/jniStaticLibs/" + abi) + commandLineArgs.add("--incremental-g-c-time-slice=3") + commandLineArgs.add("--configuration=" + configuration) + commandLineArgs.add("--dotnetprofile=unityaot-linux") + commandLineArgs.add("--profiler-report") + commandLineArgs.add("--profiler-output-file=" + workingDir + "/build/il2cpp_"+ abi + "_" + configuration + "/il2cpp_conv.traceevents") + commandLineArgs.add("--print-command-line") + commandLineArgs.add("--data-folder=" + workingDir + "/src/main/Il2CppOutputProject/Source/il2cppOutput/data") + commandLineArgs.add("--generatedcppdir=" + workingDir + "/src/main/Il2CppOutputProject/Source/il2cppOutput") + commandLineArgs.add("--cachedirectory=" + workingDir + "/build/il2cpp_"+ abi + "_" + configuration + "/il2cpp_cache") + commandLineArgs.add("--tool-chain-path=" + android.ndkDirectory) + staticLibraries.eachWithIndex {fileName, i-> + commandLineArgs.add("--additional-libraries=" + workingDir + "/src/main/jniStaticLibs/" + abi + "/" + fileName) + } + def executableExtension = "" + if (org.gradle.internal.os.OperatingSystem.current().isWindows()) { + executableExtension = ".exe" + commandLineArgs = commandLineArgs*.replace('\"', '\\\"') + } + exec { + executable workingDir + "/src/main/Il2CppOutputProject/IL2CPP/build/deploy/il2cpp" + executableExtension + args commandLineArgs + environment "ANDROID_SDK_ROOT", getSdkDir() + } + delete workingDir + "/src/main/jniLibs/" + abi + "/libil2cpp.sym.so" + ant.move(file: workingDir + "/src/main/jniLibs/" + abi + "/libil2cpp.dbg.so", tofile: workingDir + "/symbols/" + abi + "/libil2cpp.so") +} + +android { + task BuildIl2CppTask { + doLast { + BuildIl2Cpp(projectDir.toString().replaceAll('\\\\', '/'), 'Release', 'arm64', 'arm64-v8a', [ ] as String[]); + } + } + afterEvaluate { + if (project(':unityLibrary').tasks.findByName('mergeDebugJniLibFolders')) + project(':unityLibrary').mergeDebugJniLibFolders.dependsOn BuildIl2CppTask + if (project(':unityLibrary').tasks.findByName('mergeReleaseJniLibFolders')) + project(':unityLibrary').mergeReleaseJniLibFolders.dependsOn BuildIl2CppTask + } + sourceSets { + main { + jni.srcDirs = ["src/main/Il2CppOutputProject"] + } + } +} + + + diff --git a/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs b/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs index 36dbce2..75360e5 100644 --- a/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs +++ b/Tests~/WrapperPolicy/UnityGeneratedLayoutTests.cs @@ -5,18 +5,27 @@ namespace Bugsee.WrapperPolicy.Tests { /// - /// The injector over Gradle files a real Unity 6000.0.84f1 editor generated (Android Gradle project exported, - /// not built), minus Bugsee's own edits. Two fixture sets: + /// The injector over Gradle files real Unity editors generated (Unity 6000.0.84f1 exported, not built; Unity + /// 2022.3.62f3 from an APK build and from Export Project), minus Bugsee's own edits. Fixture sets: /// /// Unity6000.0.84f1-default: a bare project with no custom Gradle templates (AGP 9.0.0, Java 17 launcher). /// Unity6000.0.84f1-sample-templates: the BugseeMiniGame sample, whose root and launcher files come from /// the CUSTOM base/launcher templates the package writes into Assets/Plugins/Android (AGP 7.4.2, Java 11). + /// Unity2022.3.62f3-default: the sample's generated project from an APK build, no custom templates + /// (AGP 7.4.2, Gradle 7.5.1, Java 11 launcher; the root build.gradle is the base file). Unity 2022.3's launcher + /// has no debugSymbolLevel, so the fixture is without the release ndk { debugSymbolLevel 'FULL' } block the package patches in. + /// Unity2022.3.62f3-export: the same project from Export Project. Only unityLibrary differs: it carries + /// the IL2CPP/NDK build tasks that Unity runs itself for an APK build. /// - /// No Unity 2022 LTS export is checked in: that editor could not be installed (#48). /// public class UnityGeneratedLayoutTests { - static readonly string[] Sets = { "Unity6000.0.84f1-default", "Unity6000.0.84f1-sample-templates" }; + static readonly string[] Sets = + { + "Unity6000.0.84f1-default", "Unity6000.0.84f1-sample-templates", + "Unity2022.3.62f3-default", "Unity2022.3.62f3-export", + }; + static readonly string[] Unity2022Sets = { "Unity2022.3.62f3-default", "Unity2022.3.62f3-export" }; static readonly string[] Artifacts = { "bugsee-android", "bugsee-android-ndk" }; // The anchors and payloads BugseeAndroidGradleSetup passes to MarkedGradleBlock.Apply. @@ -148,5 +157,25 @@ public void Gradle_plugin_and_ndk_block_follow_the_launcher_application_plugin_o Assert.That(once.IndexOf("bugsee {", System.StringComparison.Ordinal), Is.LessThan(once.IndexOf("android {", System.StringComparison.Ordinal))); } + + [TestCaseSource(nameof(Unity2022Sets))] + public void Unity_2022_3_pins_an_AGP_older_than_the_Bugsee_Gradle_plugin_needs(string set) + { + string root = Read(set, "root.build.gradle"); + + Assert.That(root, Does.Contain("id 'com.android.application' version '7.4.2' apply false")); + Assert.That(Read(set, "gradle-wrapper.properties"), Does.Contain("gradle-7.5.1-bin.zip")); + } + + [Test] + public void Unity_2022_3_export_library_keeps_the_native_build_tasks_the_apk_build_leaves_out() + { + string apk = Read("Unity2022.3.62f3-default", "unityLibrary.build.gradle"); + string export = Read("Unity2022.3.62f3-export", "unityLibrary.build.gradle"); + + Assert.That(export, Does.Contain("def BuildIl2Cpp(")); + Assert.That(apk, Does.Not.Contain("def BuildIl2Cpp(")); + Assert.That(Apply(export).Text, Does.Contain("// bugsee:maven-dependencies begin")); + } } } From bfa08027910e1188190938ebd0946fae9107aaf2 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 19:03:01 +0500 Subject: [PATCH 145/157] Test the AGP rule and release symbol patch over real Unity 2022.3.62f3 generated Gradle files. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../gradle-wrapper.properties | 1 + .../gradle.properties | 8 ++ .../launcher.build.gradle | 71 ++++++++++++ .../root.build.gradle | 13 +++ .../Unity2022.3.62f3-default/settings.gradle | 23 ++++ .../unityLibrary.build.gradle | 49 ++++++++ .../gradle-wrapper.properties | 1 + .../Unity2022.3.62f3-export/gradle.properties | 8 ++ .../launcher.build.gradle | 71 ++++++++++++ .../Unity2022.3.62f3-export/root.build.gradle | 13 +++ .../Unity2022.3.62f3-export/settings.gradle | 23 ++++ .../unityLibrary.build.gradle | 108 ++++++++++++++++++ .../Unity2022GeneratedGradleTests.cs | 42 +++++++ 13 files changed, 431 insertions(+) create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle create mode 100644 Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties new file mode 100644 index 0000000..4dad05f --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties new file mode 100644 index 0000000..6092d50 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4096M +org.gradle.parallel=true +unityStreamingAssets= +unityTemplateVersion=7 +android.useAndroidX=true +android.enableJetifier=true +org.gradle.welcome=never +android.aapt2FromMavenOverride=/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/SDK/build-tools/34.0.0/aapt2 \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle new file mode 100644 index 0000000..9ed7e46 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/launcher.build.gradle @@ -0,0 +1,71 @@ +apply plugin: 'com.android.application' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "com.bugsee.minigame" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + applicationId 'com.bugsee.minigame' + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + jniDebuggable true + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + } + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } + + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.gradle new file mode 100644 index 0000000..68e3528 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/root.build.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 + +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle new file mode 100644 index 0000000..86858f1 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + + gradlePluginPortal() + google() + mavenCentral() + } +} + +include ':launcher', ':unityLibrary' + + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + + google() + mavenCentral() + flatDir { + dirs "${project(':unityLibrary').projectDir}/libs" + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle new file mode 100644 index 0000000..6add842 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-default/unityLibrary.build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.library' + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + +} + +android { + namespace "com.unity3d.player" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + consumerProguardFiles 'proguard-unity.txt' + } + + lintOptions { + abortOnError false + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } +} + + + diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties new file mode 100644 index 0000000..4dad05f --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties new file mode 100644 index 0000000..6092d50 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4096M +org.gradle.parallel=true +unityStreamingAssets= +unityTemplateVersion=7 +android.useAndroidX=true +android.enableJetifier=true +org.gradle.welcome=never +android.aapt2FromMavenOverride=/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/SDK/build-tools/34.0.0/aapt2 \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle new file mode 100644 index 0000000..9ed7e46 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/launcher.build.gradle @@ -0,0 +1,71 @@ +apply plugin: 'com.android.application' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "com.bugsee.minigame" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + applicationId 'com.bugsee.minigame' + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + jniDebuggable true + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt') + signingConfig signingConfigs.debug + } + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } + + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.gradle new file mode 100644 index 0000000..68e3528 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/root.build.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 + +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle new file mode 100644 index 0000000..86858f1 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + + gradlePluginPortal() + google() + mavenCentral() + } +} + +include ':launcher', ':unityLibrary' + + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + + google() + mavenCentral() + flatDir { + dirs "${project(':unityLibrary').projectDir}/libs" + } + } +} \ No newline at end of file diff --git a/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle new file mode 100644 index 0000000..098947c --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/Unity2022.3.62f3-export/unityLibrary.build.gradle @@ -0,0 +1,108 @@ +apply plugin: 'com.android.library' + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + +} + +android { + namespace "com.unity3d.player" + ndkPath "/Applications/Unity/Hub/Editor/2022.3.62f3/PlaybackEngines/AndroidPlayer/NDK" + compileSdkVersion 36 + buildToolsVersion '34.0.0' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion 22 + targetSdkVersion 36 + ndk { + abiFilters 'arm64-v8a' + } + versionCode 2 + versionName '0.1.0' + consumerProguardFiles 'proguard-unity.txt' + } + + lintOptions { + abortOnError false + } + + aaptOptions { + noCompress = ['.unity3d', '.ress', '.resource', '.obb', '.bundle', '.unityexp'] + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + } + + packagingOptions { + doNotStrip '*/arm64-v8a/*.so' + jniLibs { + useLegacyPackaging true + } + } +} + +def getSdkDir() { + Properties local = new Properties() + local.load(new FileInputStream("${rootDir}/local.properties")) + return local.getProperty('sdk.dir') +} + +def BuildIl2Cpp(String workingDir, String configuration, String architecture, String abi, String[] staticLibraries) { + def commandLineArgs = [] + commandLineArgs.add("--compile-cpp") + commandLineArgs.add("--platform=Android") + commandLineArgs.add("--architecture=" + architecture) + commandLineArgs.add("--outputpath=" + workingDir + "/src/main/jniLibs/" + abi + "/libil2cpp.so") + commandLineArgs.add("--baselib-directory=" + workingDir + "/src/main/jniStaticLibs/" + abi) + commandLineArgs.add("--incremental-g-c-time-slice=3") + commandLineArgs.add("--configuration=" + configuration) + commandLineArgs.add("--dotnetprofile=unityaot-linux") + commandLineArgs.add("--profiler-report") + commandLineArgs.add("--profiler-output-file=" + workingDir + "/build/il2cpp_"+ abi + "_" + configuration + "/il2cpp_conv.traceevents") + commandLineArgs.add("--print-command-line") + commandLineArgs.add("--data-folder=" + workingDir + "/src/main/Il2CppOutputProject/Source/il2cppOutput/data") + commandLineArgs.add("--generatedcppdir=" + workingDir + "/src/main/Il2CppOutputProject/Source/il2cppOutput") + commandLineArgs.add("--cachedirectory=" + workingDir + "/build/il2cpp_"+ abi + "_" + configuration + "/il2cpp_cache") + commandLineArgs.add("--tool-chain-path=" + android.ndkDirectory) + staticLibraries.eachWithIndex {fileName, i-> + commandLineArgs.add("--additional-libraries=" + workingDir + "/src/main/jniStaticLibs/" + abi + "/" + fileName) + } + def executableExtension = "" + if (org.gradle.internal.os.OperatingSystem.current().isWindows()) { + executableExtension = ".exe" + commandLineArgs = commandLineArgs*.replace('\"', '\\\"') + } + exec { + executable workingDir + "/src/main/Il2CppOutputProject/IL2CPP/build/deploy/il2cpp" + executableExtension + args commandLineArgs + environment "ANDROID_SDK_ROOT", getSdkDir() + } + delete workingDir + "/src/main/jniLibs/" + abi + "/libil2cpp.sym.so" + ant.move(file: workingDir + "/src/main/jniLibs/" + abi + "/libil2cpp.dbg.so", tofile: workingDir + "/symbols/" + abi + "/libil2cpp.so") +} + +android { + task BuildIl2CppTask { + doLast { + BuildIl2Cpp(projectDir.toString().replaceAll('\\\\', '/'), 'Release', 'arm64', 'arm64-v8a', [ ] as String[]); + } + } + afterEvaluate { + if (project(':unityLibrary').tasks.findByName('mergeDebugJniLibFolders')) + project(':unityLibrary').mergeDebugJniLibFolders.dependsOn BuildIl2CppTask + if (project(':unityLibrary').tasks.findByName('mergeReleaseJniLibFolders')) + project(':unityLibrary').mergeReleaseJniLibFolders.dependsOn BuildIl2CppTask + } + sourceSets { + main { + jni.srcDirs = ["src/main/Il2CppOutputProject"] + } + } +} + + + diff --git a/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs b/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs new file mode 100644 index 0000000..30a3e05 --- /dev/null +++ b/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs @@ -0,0 +1,42 @@ +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The AGP rule and the release symbol patch over Gradle files a real Unity 2022.3.62f3 editor generated + /// (APK build and Export Project; AGP 7.4.2, Gradle 7.5.1), minus Bugsee's own edits. + /// + public class Unity2022GeneratedGradleTests + { + static readonly string[] Sets = { "Unity2022.3.62f3-default", "Unity2022.3.62f3-export" }; + + static string Read(string set, string name) + { + return File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", set, name)); + } + + [TestCaseSource(nameof(Sets))] + public void Generated_root_pins_AGP_7_4_so_the_Bugsee_Gradle_plugin_is_not_applied(string set) + { + string root = Read(set, "root.build.gradle"); + + Assert.That(GradleTemplateDefaults.TryGetAgpVersion(root, out int major, out int minor), Is.True); + Assert.That((major, minor), Is.EqualTo((7, 4))); + Assert.That(GradleTemplateDefaults.AgpMeetsBugseePlugin(root), Is.False); + } + + [TestCaseSource(nameof(Sets))] + public void Release_symbol_level_is_added_to_the_generated_launcher_once(string set) + { + string raw = Read(set, "launcher.build.gradle"); + + string once = GradleTemplateDefaults.EnsureReleaseDebugSymbols(raw); + string twice = GradleTemplateDefaults.EnsureReleaseDebugSymbols(once); + + Assert.That(raw, Does.Not.Contain("debugSymbolLevel"), "Unity 2022.3 sets none itself"); + Assert.That(once, Does.Contain(" release {\n ndk {\n debugSymbolLevel 'FULL'\n }\n")); + Assert.That(twice, Is.EqualTo(once)); + } + } +} From 2e385a07f3889c49128b4cafdc8ec8342142faaa Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 19:08:17 +0500 Subject: [PATCH 146/157] Update the Unity 2022 verification notes to the real 2022.3.62f3 scope. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Tests~/WrapperPolicy/GradleKotlinDslTests.cs | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d484b9c..87cc590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Android Gradle injection: a project counts as declaring the SDK only for a direct `implementation`/`api`/`runtimeOnly` (or `debug`/`release` prefixed) argument of a top-level `dependencies {}` statement (coordinate string, `group`/`name` pair, or local AAR); mentions in `compileOnly`, test configurations, `exclude(...)`, `platform(...)`, `fileTree(...)`, `constraints`, `resolutionStrategy` or variables never count, wrapped over several lines or not, and an earlier injection is removed only for a confirmed declaration. A `mavenCentral { content { ... } }` repository still is Maven Central, but it only satisfies the Bugsee requirement when its filter serves `com.bugsee:bugsee-android` and `bugsee-android-ndk` (no matching `exclude*`, and any `include*` covers both); otherwise an unfiltered `mavenCentral()` is added next to it. A declaration inside an `if`/`else`/`with`/`for`/`while` block counts only for not injecting a duplicate; it never causes an earlier injection to be removed (the condition is not evaluated). Known limitations: version-catalog accessors (`libs.bugsee...`), flavor-prefixed configurations other than `debug`/`release` (a duplicate of the same coordinate may be injected) are not interpreted. Repository filters are read for `mavenCentral { content {} }`, `maven { url content {} }` and `exclusiveContent { forRepository { mavenCentral() } filter {} }`; other repository forms or unparsable filters are treated as not serving Bugsee, and an unfiltered `mavenCentral()` is added. -- Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates and by exporting (not building) the Android Gradle project with Unity 6000.0.84f1 (default and custom templates); Unity 2022 is still unverified (reconstructed Groovy fixtures) and no Gradle build was run on either. Injecting the Gradle plugin lines no longer adds an extra blank line at the end of a file that ends with a newline. +- Android Gradle injection now supports Kotlin-DSL projects (`build.gradle.kts`, `settings.gradle.kts`; dependencies are written as `implementation("g:a:v")`), and respects `repositoriesMode`: `mavenCentral()` is added to the settings repositories, also to root `allprojects` under `PREFER_PROJECT`/unset, and never to project repositories under `FAIL_ON_PROJECT_REPOS`. Bugsee declarations in `compileOnly`/test configurations or `exclude(...)` no longer count as the project declaring the SDK; `mavenCentral { ... }` blocks and a missing settings `repositories` block under `PREFER_SETTINGS`/`FAIL_ON_PROJECT_REPOS` are handled. Verified against the Unity 2021.3.45 Gradle templates and by exporting (not building) the Android Gradle project with Unity 6000.0.84f1 (default and custom templates); Unity 2022.3.62f3 was checked with real IL2CPP builds (APK build and Export Project; AGP 7.4.2, Gradle 7.5.1), its generated Gradle files are checked in under `Fixtures/Unity2022.3.62f3-*` and covered by `UnityGeneratedLayoutTests`. Injecting the Gradle plugin lines no longer adds an extra blank line at the end of a file that ends with a newline. - iOS `Bugsee.LogException(exception, options)` dropped its options. `labels`, `includeVideo` (default kept on), `mergingRules` and the top-level `skipFrames` (as on Android) now reach the SDK (`BugseeExceptionLoggingOptions`); `domain` is wrapper-owned on both platforms. The unhandled path has no options in the SDK. Android-only launch options with no iOS counterpart (`CaptureLogsUseAllSources`, `CaptureRespectFlagSecure`, `CaptureVideoMode`, `CaptureVideoUsingCustomMuxer`, `ReportProcessingInProcess`, `WebViewDomainAllowlist`) are no longer sent to iOS. The wrapper callbacks now share the tested `JsonWire` string encoder. - Android `IReport.Attributes` and `IReport.Attachments` returned empty stubs; they now read the native report. Attachment name/file name/MIME type setters are now called with the fluent `Attachment` return type, so they resolve over JNI. - Added `INetworkEvent.IsOverride` (Android: maps `NetworkEvent.isOverride`; iOS: no native equivalent, kept on the managed event only). diff --git a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs index f74bfc9..59cd0c0 100644 --- a/Tests~/WrapperPolicy/GradleKotlinDslTests.cs +++ b/Tests~/WrapperPolicy/GradleKotlinDslTests.cs @@ -7,10 +7,9 @@ namespace Bugsee.WrapperPolicy.Tests /// /// Fixture provenance: the 2021.3 settings fixture is the settingsTemplate.gradle shipped in Unity /// 2021.3.45f1 (PlaybackEngines/AndroidPlayer/Tools/GradleTemplates) with its placeholders emptied and mavenCentral() dropped from the project repositories (so there is something to insert). The - /// Kotlin and 2022 fixtures are RECONSTRUCTED (no 2022 editor could be installed; Unity 6000.0.84f1 exports are - /// checked in under Fixtures and covered by UnityGeneratedLayoutTests): Unity 2022.2+ and - /// Unity 6 keep the same Groovy settings shape (vendor docs show PREFER_SETTINGS + google/mavenCentral/flatDir), - /// and Unity generates no .kts files itself, so the .kts fixtures model a project that converted them. + /// Kotlin fixtures are RECONSTRUCTED: Unity generates no .kts files itself, so they model a + /// project that converted them. The Groovy shape Unity generates is checked in from real editors under Fixtures + /// (Unity6000.0.84f1-* and Unity2022.3.62f3-*) and covered by UnityGeneratedLayoutTests. /// public class GradleKotlinDslTests { From 517f6b36eee991b5bafd9aa5aadc72ee48eb17a0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 19:08:26 +0500 Subject: [PATCH 147/157] Make the symbol patch assertion independent of fixture line endings. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs b/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs index 30a3e05..986f960 100644 --- a/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs +++ b/Tests~/WrapperPolicy/Unity2022GeneratedGradleTests.cs @@ -35,7 +35,7 @@ public void Release_symbol_level_is_added_to_the_generated_launcher_once(string string twice = GradleTemplateDefaults.EnsureReleaseDebugSymbols(once); Assert.That(raw, Does.Not.Contain("debugSymbolLevel"), "Unity 2022.3 sets none itself"); - Assert.That(once, Does.Contain(" release {\n ndk {\n debugSymbolLevel 'FULL'\n }\n")); + Assert.That(once.Replace("\r\n", "\n"), Does.Contain(" release {\n ndk {\n debugSymbolLevel 'FULL'\n }\n")); Assert.That(twice, Is.EqualTo(once)); } } From 9522cc19c5629317799410ec59ad40c1f1bbedec Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 20:58:12 +0500 Subject: [PATCH 148/157] Find or download bugsee-cli for the Editor symbol uploads instead of relying on the Editor's PATH. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Unity Editor started from the Hub or the Finder does not inherit the shell PATH, so a bare bugsee-cli failed with "Cannot find the specified file" and no symbol was uploaded. Look at BUGSEE_CLI_PATH, PATH, the installers' directories and a cache, then download the pinned release (0.8.1) from download.bugsee.com with a SHA-256 check when a token is set. Never fails the build: one warning when nothing is found. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeCliLocator.cs | 377 ++++++++++++++++ Editor/BugseeCliLocator.cs.meta | 11 + Editor/BugseeCliRunner.cs | 19 +- Runtime/WrapperPolicy/BugseeCliPolicy.cs | 403 ++++++++++++++++++ Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta | 11 + Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 293 +++++++++++++ 6 files changed, 1112 insertions(+), 2 deletions(-) create mode 100644 Editor/BugseeCliLocator.cs create mode 100644 Editor/BugseeCliLocator.cs.meta create mode 100644 Runtime/WrapperPolicy/BugseeCliPolicy.cs create mode 100644 Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta create mode 100644 Tests~/WrapperPolicy/BugseeCliPolicyTests.cs diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs new file mode 100644 index 0000000..61f10a2 --- /dev/null +++ b/Editor/BugseeCliLocator.cs @@ -0,0 +1,377 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Bugsee.WrapperPolicy; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Bugsee.Editor +{ + /// + /// Resolves the bugsee-cli binary for the Editor symbol uploads (see for the + /// order and why): BUGSEE_CLI_PATH, PATH, the well-known install directories, a cache, then a SHA-256-verified + /// download of the pinned release from download.bugsee.com (opt out: BUGSEE_NO_CLI_DOWNLOAD; pin another release: + /// BUGSEE_CLI_VERSION; mirror: BUGSEE_CLI_BASE_URL, the variables install.sh reads). Never throws; the + /// result is remembered until the next build starts. + /// + internal static class BugseeCliLocator + { + static CliResolution _resolved; + static bool _notFoundReported; + + public static CliResolution Resolve() + { + if (_resolved != null && (!_resolved.Found || File.Exists(_resolved.Path))) + { + return _resolved; + } + + try + { + CliSettings settings = ReadSettings(); + _resolved = BugseeCliPolicy.Resolve(settings, new CliProbe + { + FileExists = File.Exists, + QueryVersion = QueryVersion, + Download = (cacheDir, version) => Download(settings, cacheDir, version), + }); + } + catch (Exception ex) + { + _resolved = new CliResolution(); + _resolved.Notes.Add("Looking for bugsee-cli failed: " + ex.Message); + } + + if (_resolved.Found) + { + Debug.Log("[Bugsee] Using bugsee-cli " + (_resolved.Version ?? "(version unknown)") + " (" + Describe(_resolved.Source) + "): " + _resolved.Path); + foreach (string note in _resolved.Notes) + { + Debug.LogWarning("[Bugsee] " + note); + } + } + + return _resolved; + } + + /// Warns once per build that no CLI was found; false when one was. + public static bool ReportIfMissing() + { + CliResolution resolution = Resolve(); + if (resolution.Found) + { + return false; + } + + if (!_notFoundReported) + { + _notFoundReported = true; + Debug.LogWarning(BugseeCliPolicy.NotFoundMessage(resolution)); + } + + return true; + } + + internal static void Reset() + { + _resolved = null; + _notFoundReported = false; + } + + static string Describe(CliSource source) + { + switch (source) + { + case CliSource.Explicit: return "BUGSEE_CLI_PATH"; + case CliSource.Path: return "PATH"; + case CliSource.KnownDirectory: return "install directory"; + case CliSource.Cache: return "cached download"; + case CliSource.Downloaded: return "downloaded"; + default: return "unresolved"; + } + } + + static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + static string HostOs => + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macos" + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : IsWindows ? "windows" : ""; + + static CliSettings ReadSettings() + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string localAppData = IsWindows ? Environment.GetEnvironmentVariable("LOCALAPPDATA") : null; + string version = Environment.GetEnvironmentVariable("BUGSEE_CLI_VERSION"); + var settings = new CliSettings + { + ExplicitPath = Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH"), + PathEnv = Environment.GetEnvironmentVariable("PATH"), + Home = home, + LocalAppData = localAppData, + IsWindows = IsWindows, + Version = string.IsNullOrWhiteSpace(version) ? BugseeCliPolicy.DefaultVersion : version.Trim().TrimStart('v'), + Triple = BugseeCliPolicy.HostTriple(HostOs, RuntimeInformation.OSArchitecture.ToString()), + DownloadAllowed = BugseeCliPolicy.DownloadAllowed( + BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), + }; + + // Shared by every project of this user first; the project's own Library when that is not writable. + string userCache = null; + if (IsWindows && !string.IsNullOrEmpty(localAppData)) + { + userCache = Path.Combine(localAppData, "Bugsee", "cache", "cli"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(home)) + { + userCache = Path.Combine(home, "Library", "Caches", "Bugsee", "cli"); + } + else if (!string.IsNullOrEmpty(home)) + { + string xdg = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + userCache = Path.Combine(string.IsNullOrEmpty(xdg) ? Path.Combine(home, ".cache") : xdg, "bugsee", "cli"); + } + + if (userCache != null) + { + settings.CacheDirectories.Add(userCache); + } + + settings.CacheDirectories.Add(Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)), "Library", "Bugsee", "cli")); + return settings; + } + + static string QueryVersion(string path) + { + string output; + int exit = Capture(path, "--version", 15000, out output); + return exit == 0 ? BugseeCliPolicy.ParseVersionOutput(output) : null; + } + + /// Runs a process, returns its exit code (-1 when it did not start or run to the end) and stdout. + static int Capture(string file, string args, int timeoutMs, out string stdout) + { + stdout = null; + try + { + var psi = new ProcessStartInfo + { + FileName = file, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using (Process proc = Process.Start(psi)) + { + if (proc == null) + { + return -1; + } + + // Both pipes are drained off-thread: a child that fills one would stall, and a hung one must not + // block the build past the timeout. + string text = null; + var outThread = new Thread(() => { try { text = proc.StandardOutput.ReadToEnd(); } catch { /* ignore */ } }); + var errThread = new Thread(() => { try { proc.StandardError.ReadToEnd(); } catch { /* ignore */ } }); + outThread.IsBackground = true; + errThread.IsBackground = true; + outThread.Start(); + errThread.Start(); + if (!proc.WaitForExit(timeoutMs)) + { + try { proc.Kill(); } catch { /* ignore */ } + return -1; + } + + outThread.Join(5000); + errThread.Join(5000); + stdout = text; + return proc.ExitCode; + } + } + catch (Exception) + { + return -1; + } + } + + static string Download(CliSettings settings, string cacheDir, string version) + { + string triple = settings.Triple; + string binaryName = BugseeCliPolicy.BinaryName(BugseeCliPolicy.IsWindowsTriple(triple)); + string finalDir = Path.GetDirectoryName(BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, triple)); + string finalBinary = Path.Combine(finalDir, binaryName); + + string baseUrl = Environment.GetEnvironmentVariable("BUGSEE_CLI_BASE_URL"); + string url = BugseeCliPolicy.ArchiveUrl(baseUrl, version, triple); + if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("refusing a non-https download URL " + url); + } + + string work = Path.Combine(Path.GetDirectoryName(finalDir), triple + ".part-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(work); + try + { + Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); + string archive = Path.Combine(work, BugseeCliPolicy.ArchiveName(triple)); + Fetch(url, archive); + string expected = BugseeCliPolicy.ParseSha256Sidecar(FetchText(BugseeCliPolicy.ChecksumUrl(url))); + if (expected == null) + { + throw new IOException("the published checksum for " + Path.GetFileName(archive) + " is malformed"); + } + + string actual = Sha256Hex(archive); + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("SHA-256 mismatch for " + Path.GetFileName(archive) + ": expected " + expected + ", got " + actual); + } + + string extracted = Path.Combine(work, "x"); + Directory.CreateDirectory(extracted); + Extract(archive, extracted, BugseeCliPolicy.IsWindowsTriple(triple)); + string binary = FindBinary(extracted, binaryName); + if (binary == null) + { + throw new IOException("the archive holds no " + binaryName); + } + + if (!BugseeCliPolicy.IsWindowsTriple(triple)) + { + int chmod = Capture("chmod", "+x \"" + binary + "\"", 15000, out _); + if (chmod != 0) + { + throw new IOException("chmod +x failed on " + binary); + } + } + + // A concurrent build may have installed the same release meanwhile: keep theirs. + Directory.CreateDirectory(finalDir); + if (!File.Exists(finalBinary)) + { + File.Copy(binary, finalBinary, true); + } + + if (!BugseeCliPolicy.IsWindowsTriple(triple)) + { + Capture("chmod", "+x \"" + finalBinary + "\"", 15000, out _); + } + + if (QueryVersion(finalBinary) == null) + { + try { File.Delete(finalBinary); } catch { /* ignore */ } + throw new IOException("the downloaded " + binaryName + " does not run on this machine"); + } + + return finalBinary; + } + finally + { + try { Directory.Delete(work, true); } catch { /* ignore */ } + } + } + + static void Fetch(string url, string destination) + { + try + { + var request = (HttpWebRequest)WebRequest.Create(url); + request.Timeout = 30000; + request.ReadWriteTimeout = 120000; + using (var response = (HttpWebResponse)request.GetResponse()) + using (Stream input = response.GetResponseStream()) + using (var output = File.Create(destination)) + { + input.CopyTo(output); + } + } + catch (Exception ex) + { + // Mono's TLS stack rejects some hosts' certificate chains: curl uses the system trust store. + string ignored; + int exit = Capture("curl", "--proto \"=https\" --tlsv1.2 -fsSL --max-time 300 -o \"" + destination + "\" \"" + url + "\"", 330000, out ignored); + if (exit != 0 || !File.Exists(destination)) + { + throw new IOException("GET " + url + " failed (" + ex.Message + "; curl exit " + exit + ")"); + } + } + } + + static string FetchText(string url) + { + string temp = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8) + ".sha256"); + try + { + Fetch(url, temp); + return File.ReadAllText(temp); + } + finally + { + try { File.Delete(temp); } catch { /* ignore */ } + } + } + + static string Sha256Hex(string path) + { + using (var sha = SHA256.Create()) + using (var stream = File.OpenRead(path)) + { + var hex = new StringBuilder(); + foreach (byte b in sha.ComputeHash(stream)) + { + hex.Append(b.ToString("x2")); + } + + return hex.ToString(); + } + } + + static void Extract(string archive, string destination, bool zip) + { + if (zip) + { + System.IO.Compression.ZipFile.ExtractToDirectory(archive, destination); + return; + } + + string ignored; + int exit = Capture("tar", "-xf \"" + archive + "\" -C \"" + destination + "\"", 120000, out ignored); + if (exit != 0) + { + throw new IOException("tar -xf " + Path.GetFileName(archive) + " failed (exit " + exit + "); xz support is needed to unpack it"); + } + } + + static string FindBinary(string root, string name) + { + foreach (string file in Directory.GetFiles(root, name, SearchOption.AllDirectories)) + { + return file; + } + + return null; + } + } + + /// Forgets the last resolution when a build starts, so a CLI installed since is found. + sealed class BugseeCliResolutionReset : IPreprocessBuildWithReport + { + public int callbackOrder => int.MinValue; + + public void OnPreprocessBuild(BuildReport report) => BugseeCliLocator.Reset(); + } +} +#endif diff --git a/Editor/BugseeCliLocator.cs.meta b/Editor/BugseeCliLocator.cs.meta new file mode 100644 index 0000000..e4b7dc5 --- /dev/null +++ b/Editor/BugseeCliLocator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b55aeccb10c4344a51132047441309e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BugseeCliRunner.cs b/Editor/BugseeCliRunner.cs index 821c895..d73f0bc 100644 --- a/Editor/BugseeCliRunner.cs +++ b/Editor/BugseeCliRunner.cs @@ -16,8 +16,18 @@ internal static class BugseeCliRunner { public const int DefaultTimeoutMs = 5 * 60 * 1000; - public static string CliPath => - Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH") ?? "bugsee-cli"; + /// + /// The resolved CLI (see ), or the bare name for messages when none was found: + /// the editor's PATH is often not the shell's, so a bare name alone is not enough. + /// + public static string CliPath + { + get + { + var resolved = BugseeCliLocator.Resolve(); + return resolved.Found ? resolved.Path : "bugsee-cli"; + } + } public static string AppToken => Environment.GetEnvironmentVariable("BUGSEE_APP_TOKEN"); @@ -31,6 +41,11 @@ public static int Run(IList args, int timeoutMs = DefaultTimeoutMs) return -1; } + if (BugseeCliLocator.ReportIfMissing()) + { + return -1; + } + var quoted = new string[args.Count]; for (var i = 0; i < args.Count; i++) { diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs new file mode 100644 index 0000000..7bae6c7 --- /dev/null +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// Where a resolved bugsee-cli came from. + public enum CliSource + { + None, + /// BUGSEE_CLI_PATH. + Explicit, + /// A directory on the editor's own PATH. + Path, + /// A directory the installers and package managers use, which the editor's PATH often lacks. + KnownDirectory, + /// The package's download cache. + Cache, + /// Downloaded for this build. + Downloaded, + } + + /// Inputs of : the host, the environment and the package's choices. + public sealed class CliSettings + { + /// BUGSEE_CLI_PATH: a file, or a bare command name to look up on PATH. + public string ExplicitPath; + public string PathEnv; + public string Home; + /// %LOCALAPPDATA% (Windows installers' target); null elsewhere. + public string LocalAppData; + public bool IsWindows; + /// The release to download when none is installed. + public string Version = BugseeCliPolicy.DefaultVersion; + /// An installed CLI older than this is passed over. + public string MinVersion = BugseeCliPolicy.MinVersion; + /// Release triple of this host; null when none is published. + public string Triple; + public bool DownloadAllowed; + /// Directories a download is cached under, in order of preference. + public IList CacheDirectories = new List(); + } + + /// The file system, the process runner and the downloader, injected so the policy is testable. + public sealed class CliProbe + { + public Func FileExists; + /// Runs path --version: the version it reports ("0.8.1"), or null when it does not run. + public Func QueryVersion; + /// Downloads and verifies the release into the directory; returns the binary's path. May throw. + public Func Download; + } + + public sealed class CliResolution + { + public string Path; + public CliSource Source; + public string Version; + /// What was skipped or failed on the way, for the message when nothing was found. + public readonly List Notes = new List(); + + public bool Found => Path != null; + } + + /// + /// Finds the bugsee-cli binary the Editor symbol uploads run. The Unity Editor is usually started from the + /// Hub or the Finder, not from a shell, so the PATH it inherits lacks the places the CLI is installed (Homebrew, + /// ~/.local/bin, ...): a bare bugsee-cli then fails with "Cannot find the specified file". Order: + /// BUGSEE_CLI_PATH, the PATH, the well-known install directories, the package's cache, and (when allowed) a + /// checksum-verified download of the pinned release from download.bugsee.com, as the Bugsee Gradle plugin does. + /// + public static class BugseeCliPolicy + { + /// The release downloaded when none is installed. Same default as the Bugsee Gradle plugin 4.0.8. + public const string DefaultVersion = "0.8.1"; + + /// + /// Oldest installed CLI used as is: 0.8.0 validates LineNumberMappings.json before packing it and uploads + /// ELF directories; older ones lack behaviour the uploads rely on. + /// + public const string MinVersion = "0.8.0"; + + public const string DefaultBaseUrl = "https://download.bugsee.com/cli"; + + public const string CliName = "bugsee-cli"; + + /// The release triple (as named in the published archives) of a host, or null when none is published. + public static string HostTriple(string os, string arch) + { + string o = (os ?? "").ToLowerInvariant(); + string a = (arch ?? "").ToLowerInvariant(); + bool arm64 = a == "arm64" || a == "aarch64"; + bool x64 = a == "x64" || a == "x86_64" || a == "amd64"; + bool mac = o.Contains("mac") || o.Contains("darwin") || o.Contains("osx"); + bool linux = o.Contains("linux"); + bool windows = o.Contains("windows") || o.Contains("win32"); + if (mac && arm64) return "aarch64-apple-darwin"; + if (mac && x64) return "x86_64-apple-darwin"; + if (linux && arm64) return "aarch64-unknown-linux-gnu"; + if (linux && x64) return "x86_64-unknown-linux-gnu"; + if (windows && x64) return "x86_64-pc-windows-msvc"; + return null; + } + + public static bool IsWindowsTriple(string triple) => triple != null && triple.Contains("windows"); + + public static string BinaryName(bool windows) => windows ? CliName + ".exe" : CliName; + + /// Archive extension: Windows releases are zips, the others tar.xz. + public static string ArchiveName(string triple) => + CliName + "-" + triple + (IsWindowsTriple(triple) ? ".zip" : ".tar.xz"); + + public static string ArchiveUrl(string baseUrl, string version, string triple) => + (string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl.Trim().TrimEnd('/')) + + "/v" + version + "/" + ArchiveName(triple); + + public static string ChecksumUrl(string archiveUrl) => archiveUrl + ".sha256"; + + /// The hash in a .sha256 sidecar ("<hex> *name" or "<hex> name"), lower-cased; null if malformed. + public static string ParseSha256Sidecar(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + string first = text.Trim().Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)[0]; + if (first.Length != 64) + { + return null; + } + + foreach (char c in first) + { + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) + { + return null; + } + } + + return first.ToLowerInvariant(); + } + + /// "0.8.1" from "bugsee-cli 0.8.1" (the last token that starts with a digit), or null. + public static string ParseVersionOutput(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + foreach (string token in output.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (char.IsDigit(token[0])) + { + return token; + } + } + + return null; + } + + /// True when is at least ; compares the numeric parts, ignoring any suffix. + public static bool VersionAtLeast(string version, string min) + { + int[] a = NumericParts(version); + int[] b = NumericParts(min); + for (int i = 0; i < Math.Max(a.Length, b.Length); i++) + { + int ai = i < a.Length ? a[i] : 0; + int bi = i < b.Length ? b[i] : 0; + if (ai != bi) + { + return ai > bi; + } + } + + return true; + } + + static int[] NumericParts(string version) + { + var parts = new List(); + foreach (string segment in (version ?? "").Split('.', '-', '+')) + { + int end = 0; + while (end < segment.Length && char.IsDigit(segment[end])) + { + end++; + } + + int value; + parts.Add(end > 0 && int.TryParse(segment.Substring(0, end), out value) ? value : 0); + } + + return parts.ToArray(); + } + + /// + /// Directories searched for the binary: the PATH, then where the installers and package managers put it + /// (install.sh: /usr/local/bin or ~/.local/bin; Homebrew: /opt/homebrew/bin; install.ps1: + /// %LOCALAPPDATA%\Bugsee\bin; ~/.bugsee/bin). The second element tells a PATH entry from a well-known one. + /// + public static List> SearchDirectories(CliSettings s) + { + var result = new List>(); + var seen = new HashSet(s.IsWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + char separator = s.IsWindows ? ';' : ':'; + + void Add(string dir, CliSource source) + { + dir = (dir ?? "").Trim().Trim('"'); + if (dir.Length > 1) + { + dir = dir.TrimEnd('/', '\\'); + } + + if (dir.Length > 0 && seen.Add(dir)) + { + result.Add(new KeyValuePair(dir, source)); + } + } + + foreach (string dir in (s.PathEnv ?? "").Split(separator)) + { + Add(dir, CliSource.Path); + } + + if (s.IsWindows) + { + if (!string.IsNullOrEmpty(s.LocalAppData)) + { + Add(Combine(s, s.LocalAppData, "Bugsee", "bin"), CliSource.KnownDirectory); + } + } + else + { + Add("/opt/homebrew/bin", CliSource.KnownDirectory); + Add("/usr/local/bin", CliSource.KnownDirectory); + if (!string.IsNullOrEmpty(s.Home)) + { + Add(Combine(s, s.Home, ".local", "bin"), CliSource.KnownDirectory); + Add(Combine(s, s.Home, ".cargo", "bin"), CliSource.KnownDirectory); + } + } + + if (!string.IsNullOrEmpty(s.Home)) + { + Add(Combine(s, s.Home, ".bugsee", "bin"), CliSource.KnownDirectory); + } + + return result; + } + + static string Combine(CliSettings s, params string[] parts) => + string.Join(s.IsWindows ? "\\" : "/", parts); + + /// Where a download of for lives under a cache directory. + public static string CachedBinaryPath(CliSettings s, string cacheDir, string version, string triple) => + Combine(s, cacheDir.TrimEnd('/', '\\'), version, triple, BinaryName(IsWindowsTriple(triple))); + + /// + /// Whether a missing CLI may be downloaded: the token is set (the upload is already opted in by it) and the + /// project did not opt out with BUGSEE_NO_CLI_DOWNLOAD. + /// + public static bool DownloadAllowed(bool tokenSet, bool optedOut) => tokenSet && !optedOut; + + public static CliResolution Resolve(CliSettings s, CliProbe probe) + { + var resolution = new CliResolution(); + + if (!string.IsNullOrWhiteSpace(s.ExplicitPath)) + { + string explicitPath = s.ExplicitPath.Trim(); + bool bareName = explicitPath.IndexOf('/') < 0 && explicitPath.IndexOf('\\') < 0; + if (!bareName) + { + if (probe.FileExists(explicitPath)) + { + resolution.Path = explicitPath; + resolution.Source = CliSource.Explicit; + resolution.Version = probe.QueryVersion(explicitPath); + if (resolution.Version != null && !VersionAtLeast(resolution.Version, s.MinVersion)) + { + resolution.Notes.Add( + "BUGSEE_CLI_PATH " + explicitPath + " is bugsee-cli " + resolution.Version + ", older than " + + s.MinVersion + "; uploads may be rejected (it is used because you set it)."); + } + + return resolution; + } + + resolution.Notes.Add("BUGSEE_CLI_PATH is set to " + explicitPath + ", which does not exist."); + } + else + { + // A bare command name (the old default was "bugsee-cli"): look it up like any other. + foreach (var entry in SearchDirectories(s)) + { + string candidate = Combine(s, entry.Key, explicitPath); + if (probe.FileExists(candidate)) + { + resolution.Path = candidate; + resolution.Source = CliSource.Explicit; + resolution.Version = probe.QueryVersion(candidate); + return resolution; + } + } + + resolution.Notes.Add("BUGSEE_CLI_PATH is set to the command " + explicitPath + ", which is not on PATH."); + } + } + + string name = BinaryName(s.IsWindows); + foreach (var entry in SearchDirectories(s)) + { + string candidate = Combine(s, entry.Key, name); + if (!probe.FileExists(candidate)) + { + continue; + } + + string version = probe.QueryVersion(candidate); + if (version == null) + { + resolution.Notes.Add(candidate + " exists but does not run (wrong architecture or not executable)."); + continue; + } + + if (!VersionAtLeast(version, s.MinVersion)) + { + resolution.Notes.Add(candidate + " is bugsee-cli " + version + ", older than " + s.MinVersion + "."); + continue; + } + + resolution.Path = candidate; + resolution.Source = entry.Value; + resolution.Version = version; + return resolution; + } + + if (s.Triple == null) + { + resolution.Notes.Add("No bugsee-cli release is published for this host, so none can be downloaded."); + return resolution; + } + + foreach (string cacheDir in s.CacheDirectories) + { + string cached = CachedBinaryPath(s, cacheDir, s.Version, s.Triple); + if (probe.FileExists(cached) && probe.QueryVersion(cached) != null) + { + resolution.Path = cached; + resolution.Source = CliSource.Cache; + resolution.Version = s.Version; + return resolution; + } + } + + if (!s.DownloadAllowed) + { + resolution.Notes.Add("Downloading bugsee-cli is disabled (BUGSEE_NO_CLI_DOWNLOAD is set)."); + return resolution; + } + + foreach (string cacheDir in s.CacheDirectories) + { + try + { + string downloaded = probe.Download(cacheDir, s.Version); + if (downloaded != null && probe.FileExists(downloaded)) + { + resolution.Path = downloaded; + resolution.Source = CliSource.Downloaded; + resolution.Version = s.Version; + return resolution; + } + } + catch (Exception ex) + { + resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " into " + cacheDir + " failed: " + ex.Message); + } + } + + return resolution; + } + + /// The warning shown (once per build) when no CLI could be resolved. + public static string NotFoundMessage(CliResolution resolution) + { + var text = new StringBuilder("Bugsee: bugsee-cli was not found, so symbols are NOT uploaded (the build continues). "); + foreach (string note in resolution.Notes) + { + text.Append(note).Append(' '); + } + + text.Append("Install it (curl --proto '=https' --tlsv1.2 -sSfL https://download.bugsee.com/cli/install.sh | sh) ") + .Append("or point BUGSEE_CLI_PATH at the binary."); + return text.ToString(); + } + } +} diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta b/Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta new file mode 100644 index 0000000..0fa01b0 --- /dev/null +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b141678a1e143dcb6748353bdfbd369 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs new file mode 100644 index 0000000..022c026 --- /dev/null +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class BugseeCliPolicyTests + { + // A fake host: the files that exist, the version each reports, and what a download produces. + sealed class Host + { + public readonly Dictionary Files = new Dictionary(); // path -> version ("" = does not run) + public readonly List Downloads = new List(); + public Func OnDownload; + + public CliProbe Probe() => new CliProbe + { + FileExists = Files.ContainsKey, + QueryVersion = p => Files.TryGetValue(p, out var v) && v != "" ? v : null, + Download = (dir, version) => + { + Downloads.Add(dir + "|" + version); + return OnDownload?.Invoke(dir, version); + }, + }; + } + + static CliSettings Mac(string path = "/usr/bin:/bin", bool download = true) => new CliSettings + { + PathEnv = path, + Home = "/Users/dev", + Triple = "aarch64-apple-darwin", + DownloadAllowed = download, + CacheDirectories = { "/Users/dev/Library/Caches/Bugsee/cli", "/proj/Library/Bugsee/cli" }, + }; + + [TestCase("macos", "Arm64", "aarch64-apple-darwin")] + [TestCase("macos", "X64", "x86_64-apple-darwin")] + [TestCase("Linux", "x86_64", "x86_64-unknown-linux-gnu")] + [TestCase("linux", "aarch64", "aarch64-unknown-linux-gnu")] + [TestCase("windows", "X64", "x86_64-pc-windows-msvc")] + [TestCase("windows", "Arm64", null)] + [TestCase("linux", "X86", null)] + [TestCase("", "X64", null)] + public void Host_triple_names_the_published_release(string os, string arch, string expected) + { + Assert.That(BugseeCliPolicy.HostTriple(os, arch), Is.EqualTo(expected)); + } + + [Test] + public void Archives_are_zip_on_windows_and_tar_xz_elsewhere() + { + Assert.That(BugseeCliPolicy.ArchiveUrl(null, "0.8.1", "aarch64-apple-darwin"), + Is.EqualTo("https://download.bugsee.com/cli/v0.8.1/bugsee-cli-aarch64-apple-darwin.tar.xz")); + Assert.That(BugseeCliPolicy.ArchiveUrl("https://mirror.example/cli/", "0.8.1", "x86_64-pc-windows-msvc"), + Is.EqualTo("https://mirror.example/cli/v0.8.1/bugsee-cli-x86_64-pc-windows-msvc.zip")); + Assert.That(BugseeCliPolicy.ChecksumUrl("https://x/y.tar.xz"), Is.EqualTo("https://x/y.tar.xz.sha256")); + } + + [Test] + public void Checksum_sidecar_is_read_in_binary_and_text_mode_and_malformed_ones_are_rejected() + { + string hash = new string('a', 32) + new string('B', 32); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(hash + " *bugsee-cli-x.tar.xz\n"), Is.EqualTo(hash.ToLowerInvariant())); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(hash + " bugsee-cli-x.tar.xz"), Is.EqualTo(hash.ToLowerInvariant())); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(hash), Is.EqualTo(hash.ToLowerInvariant())); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(""), Is.Null); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar("Not found"), Is.Null); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(new string('g', 64)), Is.Null); + } + + [TestCase("bugsee-cli 0.8.1\n", "0.8.1")] + [TestCase("bugsee-cli 0.9.0-rc.1", "0.9.0-rc.1")] + [TestCase("", null)] + [TestCase("error: nope", null)] + public void Version_is_read_from_the_cli_banner(string output, string expected) + { + Assert.That(BugseeCliPolicy.ParseVersionOutput(output), Is.EqualTo(expected)); + } + + [TestCase("0.8.1", "0.8.0", true)] + [TestCase("0.8.0", "0.8.0", true)] + [TestCase("0.7.13", "0.8.0", false)] + [TestCase("0.10.0", "0.8.1", true)] + [TestCase("1.0", "0.8.1", true)] + [TestCase("0.8.0-rc.1", "0.8.0", true)] + public void Version_comparison_is_numeric(string version, string min, bool expected) + { + Assert.That(BugseeCliPolicy.VersionAtLeast(version, min), Is.EqualTo(expected)); + } + + [Test] + public void Search_order_is_path_then_the_install_directories_without_duplicates() + { + var s = Mac("/usr/bin:/opt/homebrew/bin/:/usr/bin"); + + var dirs = BugseeCliPolicy.SearchDirectories(s); + + Assert.That(dirs.Select(d => d.Key), Is.EqualTo(new[] + { + "/usr/bin", "/opt/homebrew/bin", "/usr/local/bin", + "/Users/dev/.local/bin", "/Users/dev/.cargo/bin", "/Users/dev/.bugsee/bin", + })); + Assert.That(dirs[0].Value, Is.EqualTo(CliSource.Path)); + Assert.That(dirs[1].Value, Is.EqualTo(CliSource.Path), "a well-known directory that is also on PATH counts as PATH"); + Assert.That(dirs[2].Value, Is.EqualTo(CliSource.KnownDirectory)); + } + + [Test] + public void Windows_searches_localappdata_and_uses_semicolons() + { + var s = new CliSettings + { + IsWindows = true, + PathEnv = "C:\\Windows;C:\\tools", + Home = "C:\\Users\\dev", + LocalAppData = "C:\\Users\\dev\\AppData\\Local", + }; + + var dirs = BugseeCliPolicy.SearchDirectories(s).Select(d => d.Key).ToList(); + + Assert.That(dirs, Is.EqualTo(new[] + { + "C:\\Windows", "C:\\tools", "C:\\Users\\dev\\AppData\\Local\\Bugsee\\bin", "C:\\Users\\dev\\.bugsee\\bin", + })); + } + + [Test] + public void A_cli_in_a_directory_missing_from_the_editor_path_is_found() + { + // The reported failure: the Hub-started editor's PATH has no /opt/homebrew/bin, so a bare "bugsee-cli" + // could not be started. + var host = new Host(); + host.Files["/opt/homebrew/bin/bugsee-cli"] = "0.8.1"; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Found, Is.True); + Assert.That(r.Path, Is.EqualTo("/opt/homebrew/bin/bugsee-cli")); + Assert.That(r.Source, Is.EqualTo(CliSource.KnownDirectory)); + Assert.That(host.Downloads, Is.Empty); + } + + [Test] + public void Explicit_path_wins_over_everything_and_skips_the_version_gate() + { + var host = new Host(); + host.Files["/opt/homebrew/bin/bugsee-cli"] = "0.8.1"; + host.Files["/custom/bugsee-cli"] = "0.7.0"; + var s = Mac(); + s.ExplicitPath = "/custom/bugsee-cli"; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/custom/bugsee-cli")); + Assert.That(r.Source, Is.EqualTo(CliSource.Explicit)); + Assert.That(r.Notes.Single(), Does.Contain("older than 0.8.0")); + } + + [Test] + public void Explicit_path_that_does_not_exist_is_reported_and_discovery_continues() + { + var host = new Host(); + host.Files["/usr/local/bin/bugsee-cli"] = "0.8.1"; + var s = Mac(); + s.ExplicitPath = "/nope/bugsee-cli"; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/usr/local/bin/bugsee-cli")); + Assert.That(r.Notes.Single(), Does.Contain("/nope/bugsee-cli").And.Contain("does not exist")); + } + + [Test] + public void Explicit_bare_command_is_looked_up_in_every_search_directory() + { + var host = new Host(); + host.Files["/Users/dev/.local/bin/bugsee-cli"] = "0.8.1"; + var s = Mac(); + s.ExplicitPath = "bugsee-cli"; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/Users/dev/.local/bin/bugsee-cli")); + Assert.That(r.Source, Is.EqualTo(CliSource.Explicit)); + } + + [Test] + public void Old_or_broken_installs_are_passed_over_for_the_next_candidate() + { + var host = new Host(); + host.Files["/opt/homebrew/bin/bugsee-cli"] = "0.7.13"; + host.Files["/usr/local/bin/bugsee-cli"] = ""; + host.Files["/Users/dev/.bugsee/bin/bugsee-cli"] = "0.8.1"; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/Users/dev/.bugsee/bin/bugsee-cli")); + Assert.That(r.Notes, Has.Count.EqualTo(2)); + Assert.That(r.Notes[0], Does.Contain("0.7.13")); + Assert.That(r.Notes[1], Does.Contain("does not run")); + } + + [Test] + public void A_cached_download_is_used_without_downloading_again() + { + var host = new Host(); + host.Files["/Users/dev/Library/Caches/Bugsee/cli/0.8.1/aarch64-apple-darwin/bugsee-cli"] = "0.8.1"; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Source, Is.EqualTo(CliSource.Cache)); + Assert.That(host.Downloads, Is.Empty); + } + + [Test] + public void A_missing_cli_is_downloaded_into_the_first_cache_directory() + { + var host = new Host(); + host.OnDownload = (dir, version) => + { + string path = dir + "/" + version + "/aarch64-apple-darwin/bugsee-cli"; + host.Files[path] = version; + return path; + }; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Source, Is.EqualTo(CliSource.Downloaded)); + Assert.That(r.Path, Is.EqualTo("/Users/dev/Library/Caches/Bugsee/cli/0.8.1/aarch64-apple-darwin/bugsee-cli")); + Assert.That(host.Downloads, Is.EqualTo(new[] { "/Users/dev/Library/Caches/Bugsee/cli|0.8.1" })); + } + + [Test] + public void A_failing_download_falls_back_to_the_next_cache_directory_then_reports_every_failure() + { + var host = new Host(); + host.OnDownload = (dir, version) => throw new InvalidOperationException("SHA-256 mismatch in " + dir); + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Found, Is.False); + Assert.That(host.Downloads, Has.Count.EqualTo(2)); + Assert.That(r.Notes, Has.Count.EqualTo(2)); + string message = BugseeCliPolicy.NotFoundMessage(r); + Assert.That(message, Does.Contain("SHA-256 mismatch").And.Contain("BUGSEE_CLI_PATH").And.Contain("install.sh")); + Assert.That(message, Does.Contain("build continues")); + } + + [Test] + public void Download_is_not_attempted_when_it_is_not_allowed() + { + var host = new Host(); + + var r = BugseeCliPolicy.Resolve(Mac(download: false), host.Probe()); + + Assert.That(r.Found, Is.False); + Assert.That(host.Downloads, Is.Empty); + Assert.That(r.Notes.Single(), Does.Contain("BUGSEE_NO_CLI_DOWNLOAD")); + } + + [Test] + public void A_host_without_a_published_release_gets_a_note_and_no_download() + { + var host = new Host(); + var s = Mac(); + s.Triple = null; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Found, Is.False); + Assert.That(host.Downloads, Is.Empty); + Assert.That(r.Notes.Single(), Does.Contain("No bugsee-cli release is published")); + } + + [TestCase(true, false, true)] + [TestCase(true, true, false)] + [TestCase(false, false, false)] + public void Download_needs_the_token_and_respects_the_opt_out(bool token, bool optedOut, bool expected) + { + Assert.That(BugseeCliPolicy.DownloadAllowed(token, optedOut), Is.EqualTo(expected)); + } + + [Test] + public void Cache_layout_is_version_then_triple() + { + Assert.That( + BugseeCliPolicy.CachedBinaryPath(new CliSettings(), "/c", "0.8.1", "x86_64-pc-windows-msvc"), + Is.EqualTo("/c/0.8.1/x86_64-pc-windows-msvc/bugsee-cli.exe")); + } + } +} From f9845ecb340fe8502b980b071be6db33fac82188 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 20:58:12 +0500 Subject: [PATCH 149/157] Recover the Gradle plugin's build id, mapping id and icon on Unity 2021.3 / 2022.3 (AGP 7.4.2). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugsee Gradle plugin 4.0.8 needs AGP 8.6+ and cannot load on the AGP 7.4.2 those Unity versions ship. The launcher gets a small block that writes assets/bugsee_build_id.properties from the R8 mapping (checked on AGP 7.4.2 / Gradle 7.5.1: no toListenTo, and transforming ASSETS drops every library asset). Without it the SDK derives its id from token, version name and code, and the mapping and ELF uploads are keyed by that. The mapping upload also attaches the launcher icon. Docs and changelog state what is not recovered. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 3 + Documentation~/index.md | 27 ++- Editor/BugseeAndroidGradleSetup.cs | 40 ++++- Editor/BugseeBuildIdentity.cs | 18 +- Editor/BugseeSymbolUpload.cs | 55 +++++- README.md | 2 +- Runtime/WrapperPolicy/AndroidBuildUuid.cs | 52 ++++++ .../WrapperPolicy/AndroidBuildUuid.cs.meta | 11 ++ Runtime/WrapperPolicy/AndroidIconPicker.cs | 62 +++++++ .../WrapperPolicy/AndroidIconPicker.cs.meta | 11 ++ Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 167 ++++++++++++++++++ .../WrapperPolicy/GradleBuildIdBlock.cs.meta | 11 ++ .../WrapperPolicy/GradleTemplateDefaults.cs | 11 ++ Runtime/WrapperPolicy/MarkedGradleBlock.cs | 6 +- Tests~/WrapperPolicy/AndroidBuildUuidTests.cs | 32 ++++ .../WrapperPolicy/AndroidIconPickerTests.cs | 37 ++++ .../2022.3.62f3/baseProjectTemplate.gradle | 13 ++ .../2022.3.62f3/launcherTemplate.gradle | 62 +++++++ .../WrapperPolicy/GradleBuildIdBlockTests.cs | 105 +++++++++++ .../OldUnitySymbolUploadSourceTests.cs | 105 +++++++++++ 20 files changed, 816 insertions(+), 14 deletions(-) create mode 100644 Runtime/WrapperPolicy/AndroidBuildUuid.cs create mode 100644 Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta create mode 100644 Runtime/WrapperPolicy/AndroidIconPicker.cs create mode 100644 Runtime/WrapperPolicy/AndroidIconPicker.cs.meta create mode 100644 Runtime/WrapperPolicy/GradleBuildIdBlock.cs create mode 100644 Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidBuildUuidTests.cs create mode 100644 Tests~/WrapperPolicy/AndroidIconPickerTests.cs create mode 100644 Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle create mode 100644 Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle create mode 100644 Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs create mode 100644 Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aca7f8..7ffa0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a SHA-256-verified download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set (`BUGSEE_NO_CLI_DOWNLOAD=1` opts out, `BUGSEE_CLI_VERSION` and `BUGSEE_CLI_BASE_URL` override). A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered: the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute), so the mapping upload matches what the SDK reports and Java frames of minified builds deobfuscate. If the block cannot be added, or R8 is off, the SDK derives its own id from token, version name and version code and the mapping is uploaded under that id. The mapping upload now also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and verification in `Documentation~/index.md`. + - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. - Android Mono builds cannot symbolicate native frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`): Unity 2021.3 has no Mono ARM64 and those libraries have no GNU build-id. IL2CPP is the supported configuration for fully symbolicated native crashes. Documented in the Player Settings checklist, and a Mono Android build now logs a one-time warning at build time regardless of symbol upload settings (the build is never failed). diff --git a/Documentation~/index.md b/Documentation~/index.md index 2c0bdba..aa9f05d 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -126,7 +126,13 @@ IL2CPP **C# file/line** via LNM always needs uploaded `il2cpp-linemap` + matchin ## Symbol upload (Editor / CI) -Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`. +Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`, `BUGSEE_BUILD_UUID`. + +### Where `bugsee-cli` comes from (Android Editor uploads) + +A Unity Editor started from the Hub or the Finder does not inherit your shell's `PATH`, so a CLI installed with Homebrew or `install.sh` is often invisible to it. The Editor uploads therefore look, in order, at: `BUGSEE_CLI_PATH`; the Editor's `PATH`; the places the installers use (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`); the package's cache (`~/Library/Caches/Bugsee/cli` on macOS, `$XDG_CACHE_HOME/bugsee/cli` or `~/.cache/bugsee/cli` on Linux, `%LOCALAPPDATA%\Bugsee\cache\cli` on Windows, else `Library/Bugsee/cli` in the project). An installed CLI older than 0.8.0 is passed over. + +When none is found and a token is set, the pinned release (0.8.1, the Bugsee Gradle plugin's default) is downloaded from `https://download.bugsee.com/cli/v/bugsee-cli-.tar.xz` (`.zip` on Windows), checked against the published `.sha256`, unpacked into that cache and run once to prove it works. The checksum comes from the same host as the archive, so it detects corruption, not a compromised host: set `BUGSEE_CLI_PATH` to a binary you vetted if that matters. `BUGSEE_NO_CLI_DOWNLOAD=1` turns the download off, `BUGSEE_CLI_VERSION` pins another release and `BUGSEE_CLI_BASE_URL` points at a mirror (https only), as for `install.sh`. If nothing can be found or downloaded the build warns once, naming what was tried, and continues without uploading. The iOS Run Script still starts a bare `bugsee-cli` (or `BUGSEE_CLI_PATH`) and does not use this lookup. The iOS Run Script uploads for **every** Xcode configuration (Debug included, so a crash from a development build is symbolicated) and runs on every build that has the token in its environment, not only archives. Set `BUGSEE_SKIP_UPLOAD_ON_DEBUG=1` to skip the Debug configuration, for example for local Xcode runs. @@ -146,7 +152,24 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn 1. Scripting Backend: **IL2CPP** is the supported configuration for fully symbolicated native crashes. With Mono, frames inside the Mono runtime (`libmonobdwgc-2.0.so`, `libmono-native.so`, `libMonoPosixHelper.so`) cannot be symbolicated: Unity 2021.3 has no Mono ARM64 (ARMv7 only) and those libraries have no GNU build-id. Frames in `libunity.so` and `libmain.so` still symbolicate. A Mono Android build logs a one-time warning at build time (whether or not symbol upload is configured) and is never failed 2. Create symbols.zip / native debug symbols enabled 3. For primary LNM apply: native debug symbol level **FULL** (DWARF lines), not SYMBOL_TABLE-only -4. No custom Gradle templates needed: Bugsee patches the generated Gradle project (the Bugsee Gradle plugin with `ndk { enabled = true }` when it pins AGP 8.6+, i.e. Unity 6); a template of your own that pins another AGP major than your Unity's default fails the build and is reported +4. No custom Gradle templates needed: Bugsee patches the generated Gradle project (the Bugsee Gradle plugin with `ndk { enabled = true }` when it pins AGP 8.6+, i.e. Unity 6; on older AGPs, see below); a template of your own that pins another AGP major than your Unity's default fails the build and is reported + +### Unity 2021.3 and 2022.3 (no Bugsee Gradle plugin) + +Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle 8.7+ and fails to load there (checked on 2022.3.62f3: `NoClassDefFoundError: KotlinCompilerPluginSupportPlugin`, then `NoSuchMethodError` on `OutOperationRequest.toListenTo`). What it does for a Unity app, and what stands in for it: + +| Gradle plugin function | Unity 6 | Unity 2021.3 / 2022.3 | +|---|---|---| +| Declare `bugsee-android`, `bugsee-android-ndk` | Package (Maven injection) | Same | +| `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`); needs AGP 7.4 to 8.5. With R8 off no asset is written and the SDK derives its own id | +| `com.bugsee.android.BUILD_UUID` manifest meta-data (fallback id when R8 is off) | Plugin | Not added: the SDK derives `UUID(token, versionName, versionCode)` itself when it finds neither; the uploads use that id (`AndroidBuildUuid`) | +| Mapping upload (`--uuid`, `--icon`) | Package Editor upload (the plugin finds no app token in a Unity build, so its own upload skips) | Same: mapping, same id as the asset, launcher icon attached | +| Native symbol upload | Package Editor upload of Unity's `symbols.zip` (same reason) | Same | +| NDK enabled | Plugin | Not applicable: the NDK artifact is declared and the Editor uploads the symbols | +| Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | +| Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | + +Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. If you do minify, add keep rules for the Unity wrapper classes: the package's `bugsee-unity-wrapper.proguard` is not applied by Unity unless you enable Player Settings > Publishing Settings > Custom Proguard File and copy the rules into `Assets/Plugins/Android/proguard-user.txt`, and that file does not yet cover `com.bugsee.unity.UnityManagedException` or `UnityWrapperIdentity` (R8 removes them: `ClassNotFoundException` at launch or on the first managed exception). **iOS** diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 5040551..4a90105 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -28,14 +28,28 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport, IPostGenerat public int callbackOrder => 10; public const string GradlePluginVersion = "4.0.8"; + const string GradlePluginMarker = "// bugsee:gradle-plugin"; public const string SdkVersion = BugseePackageVersion.AndroidSdkVersion; const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; + /// + /// Whether the current Android build writes the mapping-derived build id into the app, either through the Bugsee + /// Gradle plugin or through (AGP too old for the plugin). Then the CLI derives + /// the same id from the mapping on its own. Otherwise the SDK derives the id from token, version name and + /// code, and the uploads must be keyed by that (). + /// + internal static bool BuildIdAssetInjected { get; private set; } + + /// The generated Gradle project of the current Android build (the folder with settings.gradle), or null. + internal static string GeneratedProjectRoot { get; private set; } + public void OnPreprocessBuild(BuildReport report) { if (report.summary.platform == BuildTarget.Android) { + BuildIdAssetInjected = false; + GeneratedProjectRoot = null; RemoveLegacyTemplates(); } } @@ -61,7 +75,8 @@ public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) { try { - PatchGeneratedProject(Path.GetDirectoryName(Path.GetFullPath(unityLibraryPath))); + GeneratedProjectRoot = Path.GetDirectoryName(Path.GetFullPath(unityLibraryPath)); + PatchGeneratedProject(GeneratedProjectRoot); } catch (Exception ex) when (!(ex is BuildFailedException)) { @@ -104,6 +119,29 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } + bool pluginPresent = apply && + patchedRoot.Contains(GradlePluginMarker) && patchedLauncher.Contains(GradlePluginMarker); + bool blockPresent = false; + if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + { + patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); + blockPresent = blockStatus != BuildIdBlockStatus.NoAnchor; + if (blockPresent) + { + Debug.Log( + "[Bugsee] Added the build id asset (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + + ": the part of the Bugsee Gradle plugin that works on this AGP."); + } + else + { + Debug.LogWarning( + "[Bugsee] No top-level android { } block in " + launcherPath + ", so the build id asset was not added. " + + "The SDK derives its own build id and the mapping is uploaded under it."); + } + } + + BuildIdAssetInjected = pluginPresent || blockPresent; + if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { File.WriteAllText(rootPath, patchedRoot); diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index c77aa45..957f1af 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -67,8 +67,9 @@ public static string ResolveBuildNumber(BuildReport report) } /// - /// Correlation BUILD_UUID for bugsee-cli --type elf --uuid. - /// Per-.so GNU build-ids remain the real symbol keys. + /// BUILD_UUID the symbol uploads are keyed by (bugsee-cli --uuid): BUGSEE_BUILD_UUID, else on Android the + /// id the SDK derives itself for an app the Gradle plugin did not build (: token, + /// version name and version code as the APK carries them, not BUGSEE_VERSION/BUGSEE_BUILD), else a random one. Per-.so GNU build-ids remain the real native symbol keys. /// public static string ResolveBuildUuid(BuildReport report) { @@ -78,7 +79,18 @@ public static string ResolveBuildUuid(BuildReport report) return env.Trim(); } - // Prefer a stable dashed UUID when BUGSEE_BUILD_UUID is unset. + if (report?.summary.platform == BuildTarget.Android) + { + var names = AndroidNameSettings(report); + var synthesized = AndroidBuildUuid.Synthesize( + BugseeCliRunner.AppToken, names.BundleVersion, names.VersionCode.ToString()); + if (synthesized != null) + { + return synthesized; + } + } + + // Prefer a stable dashed UUID when nothing above applies. return Guid.NewGuid().ToString("D"); } diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 84831fd..92dafdc 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -74,12 +74,52 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. var stem = OutputStem(report); var allowGeneric = OutputIsDirectory(report); + // The Gradle plugin (or its stand-in, GradleBuildIdBlock) writes the mapping's content id into the app, and the + // CLI derives the same id on its own. Without either the SDK reports an id built from token, version name + // and code, so the mapping is uploaded under that one or its Java frames would never be deobfuscated. + var mappingExtra = new List(); + if (!BugseeAndroidGradleSetup.BuildIdAssetInjected) + { + var buildUuid = BugseeBuildIdentity.ResolveBuildUuid(report); + mappingExtra.Add("--uuid"); + mappingExtra.Add(buildUuid); + } + + // The Gradle plugin attaches the launcher icon to the mapping (the app's icon in the dashboard). + var icon = FindLauncherIcon(); + if (icon != null) + { + Debug.Log($"Bugsee: attaching launcher icon {icon} to the mapping upload."); + mappingExtra.Add("--icon"); + mappingExtra.Add(icon); + } + UploadIfFound(roots, "*mapping.txt", "proguard", version, build, - path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric)); + path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra); // Unity "Create symbols.zip" / Gradle native debug symbols. UploadSymbolsZipOrElf(report, roots, version, build); } + static string FindLauncherIcon() + { + var root = BugseeAndroidGradleSetup.GeneratedProjectRoot; + var res = string.IsNullOrEmpty(root) ? null : Path.Combine(root, "launcher", "src", "main", "res"); + if (res == null || !Directory.Exists(res)) + { + return null; + } + + try + { + return AndroidIconPicker.Pick(Directory.EnumerateFiles(res, "app_icon.*", SearchOption.AllDirectories)); + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: launcher icon scan failed under {res}: {ex.Message}"); + return null; + } + } + static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build) { var bestZip = FindSymbolsZip(report); @@ -173,7 +213,8 @@ static void UploadIfFound( string type, string version, string build, - Func rank) + Func rank, + IList extraArgs = null) { string best = null; var bestRank = BuildArtifactChoice.Reject; @@ -217,13 +258,19 @@ static void UploadIfFound( } Debug.Log($"Bugsee: uploading {type} via {BugseeCliRunner.CliPath}: {best}"); - var exit = BugseeCliRunner.Run(new[] + var args = new List { "debug-files", "upload", best, "--type", type, "--version", version, "--build", build, - }); + }; + if (extraArgs != null) + { + args.AddRange(extraArgs); + } + + var exit = BugseeCliRunner.Run(args); if (exit != 0) { Debug.LogWarning($"Bugsee: bugsee-cli {type} upload failed (exit {exit}) — build continues."); diff --git a/README.md b/README.md index 7e11c85..26ee074 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ log warns when its version differs from the one this package pins. | `bugsee-android` | **7.4.1**, added to `unityLibrary/build.gradle` at build time (no Maven transitives) | | `bugsee-android-ndk` | Same version, declared alongside (native fatal capture for IL2CPP primary LNM) | | `mavenCentral()` | Added to the generated project repositories only if Unity's layout lacks it | -| Gradle plugin `4.0.8` | Patched into the generated Gradle project by `BugseeAndroidGradleSetup` when it pins AGP 8.6+ (Unity 6; `ndk { enabled = true }`) | +| Gradle plugin `4.0.8` | Patched into the generated Gradle project by `BugseeAndroidGradleSetup` when it pins AGP 8.6+ (Unity 6; `ndk { enabled = true }`). On Unity 2021.3 / 2022.3 (AGP 7.4.2) it cannot run; the build-id asset is added instead and symbols are uploaded by the Editor with `bugsee-cli` found or downloaded automatically, see [Documentation~/index.md](Documentation~/index.md#unity-20213-and-20223-no-bugsee-gradle-plugin) | ### Refreshing native artifacts diff --git a/Runtime/WrapperPolicy/AndroidBuildUuid.cs b/Runtime/WrapperPolicy/AndroidBuildUuid.cs new file mode 100644 index 0000000..e6edc3f --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidBuildUuid.cs @@ -0,0 +1,52 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Bugsee.WrapperPolicy +{ + /// + /// The build identity the Bugsee Android SDK derives on its own when the app is not built by the Bugsee Gradle + /// plugin (no bugsee_build_id.properties asset, no com.bugsee.android.BUILD_UUID meta-data): the + /// third channel of BugseeEnvironment.getApplicationBuildId (SDK 7.0.0-beta13+), the MD5 name-based UUID + /// of appToken + 0x1F + versionName + 0x1F + versionCode (Java's UUID.nameUUIDFromBytes). + /// + /// Unity 2021.3 and 2022.3 cannot run the Gradle plugin (it needs AGP 8.6+), so a mapping or native symbol + /// upload from those editors has to be keyed by this value, or the server can never match it to a crash. + /// + public static class AndroidBuildUuid + { + const char Separator = '\u001f'; + + /// The SDK's synthesized build id, or null when the token or version code is missing. + public static string Synthesize(string appToken, string versionName, string versionCode) + { + if (string.IsNullOrEmpty(appToken) || string.IsNullOrEmpty(versionCode)) + { + return null; + } + + string input = appToken.Trim() + Separator + (versionName ?? "") + Separator + versionCode.Trim(); + byte[] hash; + using (var md5 = MD5.Create()) + { + hash = md5.ComputeHash(new UTF8Encoding(false).GetBytes(input)); + } + + // Java's nameUUIDFromBytes: version 3, IETF variant, rendered big-endian. + hash[6] = (byte)((hash[6] & 0x0f) | 0x30); + hash[8] = (byte)((hash[8] & 0x3f) | 0x80); + var hex = new StringBuilder(36); + for (int i = 0; i < 16; i++) + { + if (i == 4 || i == 6 || i == 8 || i == 10) + { + hex.Append('-'); + } + + hex.Append(hash[i].ToString("x2")); + } + + return hex.ToString(); + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta b/Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta new file mode 100644 index 0000000..9129479 --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidBuildUuid.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0848575233d243f48b22c1d07da6aa4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/AndroidIconPicker.cs b/Runtime/WrapperPolicy/AndroidIconPicker.cs new file mode 100644 index 0000000..2d1038b --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidIconPicker.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Picks the launcher icon the Bugsee Gradle plugin attaches to the mapping upload (bugsee-cli --icon, shown + /// as the app's icon in the dashboard): the highest-density raster app_icon of the generated launcher + /// module (Unity names it @mipmap/app_icon). Adaptive icons are XML and have no raster to send. + /// + public static class AndroidIconPicker + { + static readonly string[] Densities = { "ldpi", "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi" }; + static readonly string[] Extensions = { ".png", ".webp", ".jpg", ".jpeg" }; + + /// The best candidate among (paths ending mipmap-*/app_icon.png and similar), or null. + public static string Pick(IEnumerable files) + { + string best = null; + int bestRank = -1; + foreach (string file in files) + { + int rank = Rank(file); + if (rank > bestRank) + { + best = file; + bestRank = rank; + } + } + + return best; + } + + static int Rank(string file) + { + string normalized = (file ?? "").Replace('\\', '/'); + string name = Path.GetFileName(normalized); + bool raster = false; + foreach (string ext in Extensions) + { + raster |= string.Equals(name, "app_icon" + ext, StringComparison.OrdinalIgnoreCase); + } + + if (!raster) + { + return -1; + } + + string folder = Path.GetFileName(Path.GetDirectoryName(normalized) ?? ""); + for (int i = Densities.Length - 1; i >= 0; i--) + { + if (folder.EndsWith("-" + Densities[i], StringComparison.OrdinalIgnoreCase)) + { + return i + 1; + } + } + + return 0; + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidIconPicker.cs.meta b/Runtime/WrapperPolicy/AndroidIconPicker.cs.meta new file mode 100644 index 0000000..81866bf --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidIconPicker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ddd335a800a94588be5c81db0d693407 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs new file mode 100644 index 0000000..3289044 --- /dev/null +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; + +namespace Bugsee.WrapperPolicy +{ + public enum BuildIdBlockStatus + { + /// The block was added or brought up to date. + Applied, + /// The block was already there and current. + AlreadyCurrent, + /// No top-level android { block to put it before: left alone. + NoAnchor, + } + + /// + /// The part of the Bugsee Gradle plugin that gives the app its build id, for Android Gradle plugins too old to run + /// the plugin (Unity 2021.3 / 2022.3 pin AGP 7.4.2; the plugin needs 8.6+). After R8 it writes + /// assets/bugsee_build_id.properties holding the UUID of the R8 mapping (MD5 name-based, what the plugin + /// and bugsee-cli --type proguard compute), which is what the SDK reports at runtime. The mapping upload + /// then needs no --uuid: the CLI derives the same id from the file. With no mapping (R8 off) no asset is + /// written and the SDK derives its own id (). + /// + /// Uses only API present in AGP 7.4 (artifacts.get(OBFUSCATION_MAPPING_FILE) and a generated assets source + /// directory), not toListenTo (missing in AGP 7.4) and not a transform of the ASSETS artifact (in AGP 7.4.2 + /// that replaced the merged assets and dropped every library asset, Unity's game data among them; checked on + /// AGP 7.4.2 / Gradle 7.5.1). + /// + public static class GradleBuildIdBlock + { + public const string Begin = "// bugsee:build-id begin"; + public const string End = "// bugsee:build-id end"; + public const string AssetName = "bugsee_build_id.properties"; + + public static string Snippet() => string.Join("\n", new[] + { + Begin, + "// Written by the Bugsee Unity package: the Bugsee Gradle plugin cannot run on this Android Gradle plugin.", + "abstract class BugseeResolveBuildIdTask extends DefaultTask {", + " @InputFile @Optional @PathSensitive(PathSensitivity.NONE)", + " abstract RegularFileProperty getMappingFile()", + " @OutputFile", + " abstract RegularFileProperty getIdFile()", + " @TaskAction", + " void run() {", + " def mapping = mappingFile.orNull?.asFile", + " def out = idFile.get().asFile", + " out.parentFile.mkdirs()", + " out.text = (mapping != null && mapping.isFile() && mapping.length() > 0) ? java.util.UUID.nameUUIDFromBytes(mapping.bytes).toString() : ''", + " }", + "}", + "abstract class BugseeBuildIdAssetTask extends DefaultTask {", + " @InputFile @PathSensitive(PathSensitivity.NONE)", + " abstract RegularFileProperty getIdFile()", + " @OutputDirectory", + " abstract DirectoryProperty getOutputDir()", + " @TaskAction", + " void run() {", + " def dst = outputDir.get().asFile", + " project.delete(dst)", + " dst.mkdirs()", + " def id = idFile.get().asFile.text.trim()", + " if (!id.isEmpty()) {", + " new File(dst, '" + AssetName + "').text = \"# Bugsee build identifier - auto-generated, do not edit.\\nbugsee.build_id=${id}\\n\"", + " }", + " }", + "}", + "// A failure here only costs the build id (the SDK then derives its own), never the build.", + "if (project.extensions.findByName('androidComponents') != null) {", + " androidComponents {", + " onVariants(selector().all()) { variant ->", + " try {", + " def cap = variant.name.capitalize()", + " def mapping = variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.OBFUSCATION_MAPPING_FILE.INSTANCE)", + " def resolve = tasks.register(\"resolveBugsee${cap}BuildId\", BugseeResolveBuildIdTask) {", + " it.idFile.set(layout.buildDirectory.file(\"bugsee/${variant.name}/build-id.txt\"))", + " }", + " resolve.configure { it.mappingFile.set(mapping) }", + " def asset = tasks.register(\"injectBugsee${cap}BuildId\", BugseeBuildIdAssetTask) {", + " it.idFile.set(resolve.flatMap { it.idFile })", + " }", + " variant.sources.assets.addGeneratedSourceDirectory(asset, { it.outputDir })", + " } catch (Throwable t) {", + " logger.warn(\"Bugsee: could not add the build id asset to ${variant.name}: ${t.message}\")", + " }", + " }", + " }", + "}", + End, + }); + + /// + /// Adds the block before the launcher's top-level android { line, or replaces the one a previous run + /// left (an append export, an upgraded package). + /// + public static string Apply(string launcherGradle, out BuildIdBlockStatus status) + { + if (launcherGradle == null) + { + throw new ArgumentNullException(nameof(launcherGradle)); + } + + var lines = MarkedGradleBlock.SplitLines(launcherGradle, out string newline); + var snippet = new List(Snippet().Split('\n')); + + int begin = lines.FindIndex(l => l.Trim() == Begin); + int end = begin < 0 ? -1 : lines.FindIndex(begin, l => l.Trim() == End); + if (begin >= 0 && end > begin) + { + var current = lines.GetRange(begin, end - begin + 1); + if (SameLines(current, snippet)) + { + status = BuildIdBlockStatus.AlreadyCurrent; + return launcherGradle; + } + + lines.RemoveRange(begin, end - begin + 1); + lines.InsertRange(begin, snippet); + status = BuildIdBlockStatus.Applied; + return MarkedGradleBlock.JoinLines(lines, newline, false); + } + + int depth = 0; + bool inBlockComment = false; + int anchor = -1; + for (int i = 0; i < lines.Count; i++) + { + if (!inBlockComment && depth == 0 && lines[i].TrimStart().StartsWith("android {", StringComparison.Ordinal)) + { + anchor = i; + break; + } + + MarkedGradleBlock.ScanLine(lines[i], ref depth, ref inBlockComment); + } + + if (anchor < 0) + { + status = BuildIdBlockStatus.NoAnchor; + return launcherGradle; + } + + snippet.Add(""); + lines.InsertRange(anchor, snippet); + status = BuildIdBlockStatus.Applied; + return MarkedGradleBlock.JoinLines(lines, newline, false); + } + + static bool SameLines(List a, List b) + { + if (a.Count != b.Count) + { + return false; + } + + for (int i = 0; i < a.Count; i++) + { + if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + } +} diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta new file mode 100644 index 0000000..85ff7be --- /dev/null +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8b44ea53f574cd6b62819395a8f4272 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 6d01491..44c73c1 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -91,6 +91,17 @@ public static bool AgpMeetsBugseePlugin(string rootBuildFile) (major > 8 || (major == 8 && minor >= 6)); } + /// + /// needs the AGP 7.4 artifacts API (checked on 7.4.2) and is for versions the + /// plugin cannot run on (below 8.6): 7.4 up to 8.5. False for older AGPs (Unity 2021.3's first patch releases + /// ship AGP 4.0.1, which has no androidComponents) and when the version is unknown. + /// + public static bool AgpSupportsBuildIdBlock(string rootBuildFile) + { + return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && + ((major == 7 && minor >= 4) || (major == 8 && minor < 6)); + } + const string PluginMarker = "bugsee:gradle-plugin"; // The baseProjectTemplate.gradle earlier package versions wrote on every Unity line (Unity 2021.3's default diff --git a/Runtime/WrapperPolicy/MarkedGradleBlock.cs b/Runtime/WrapperPolicy/MarkedGradleBlock.cs index 33146c7..8f2de92 100644 --- a/Runtime/WrapperPolicy/MarkedGradleBlock.cs +++ b/Runtime/WrapperPolicy/MarkedGradleBlock.cs @@ -469,7 +469,7 @@ static void ReplaceLines(List lines, int start, int end, List re lines.InsertRange(start, replacement); } - static string JoinLines(List lines, string newline, bool trailingNewline) + internal static string JoinLines(List lines, string newline, bool trailingNewline) { var sb = new StringBuilder(); for (int i = 0; i < lines.Count; i++) @@ -648,7 +648,7 @@ static bool IsActiveBugseePluginLine(string line) trimmed.IndexOf("apply plugin", StringComparison.Ordinal) >= 0; } - static List SplitLines(string text, out string newline) + internal static List SplitLines(string text, out string newline) { newline = text.IndexOf("\r\n", StringComparison.Ordinal) >= 0 ? "\r\n" : "\n"; var lines = new List(); @@ -678,7 +678,7 @@ static List SplitLines(string text, out string newline) return lines; } - static void ScanLine(string line, ref int depth, ref bool inBlockComment) + internal static void ScanLine(string line, ref int depth, ref bool inBlockComment) { for (int i = 0; i < line.Length; i++) { diff --git a/Tests~/WrapperPolicy/AndroidBuildUuidTests.cs b/Tests~/WrapperPolicy/AndroidBuildUuidTests.cs new file mode 100644 index 0000000..fddf832 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidBuildUuidTests.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidBuildUuidTests + { + // Expected values come from the SDK's own algorithm: java.util.UUID.nameUUIDFromBytes( + // (token + "\u001f" + versionName + "\u001f" + versionCode).getBytes(UTF_8)). + [TestCase("sampletoken", "1.2.3", "45", "0f5c0792-affb-3962-af95-d6e66c5828e2")] + [TestCase("tok", "0.1.0", "1", "5d8da6e2-8821-3d5b-88f9-b76fd271ed4e")] + [TestCase("tökén", "1.0-é", "2147483647", "62fe7fa8-3bfd-3ff4-b6d5-141c321f240c")] + public void Matches_the_android_sdks_synthesized_build_id(string token, string versionName, string versionCode, string expected) + { + Assert.That(AndroidBuildUuid.Synthesize(token, versionName, versionCode), Is.EqualTo(expected)); + } + + [Test] + public void Whitespace_around_the_token_and_code_does_not_change_the_id() + { + Assert.That(AndroidBuildUuid.Synthesize(" tok\n", "0.1.0", " 1 "), Is.EqualTo("5d8da6e2-8821-3d5b-88f9-b76fd271ed4e")); + } + + [Test] + public void Missing_token_or_code_yields_none_and_a_missing_name_counts_as_empty() + { + Assert.That(AndroidBuildUuid.Synthesize(null, "1", "1"), Is.Null); + Assert.That(AndroidBuildUuid.Synthesize("", "1", "1"), Is.Null); + Assert.That(AndroidBuildUuid.Synthesize("t", "1", null), Is.Null); + Assert.That(AndroidBuildUuid.Synthesize("t", null, "1"), Is.EqualTo(AndroidBuildUuid.Synthesize("t", "", "1"))); + } + } +} diff --git a/Tests~/WrapperPolicy/AndroidIconPickerTests.cs b/Tests~/WrapperPolicy/AndroidIconPickerTests.cs new file mode 100644 index 0000000..e04a759 --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidIconPickerTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidIconPickerTests + { + [Test] + public void The_highest_density_raster_app_icon_wins() + { + var picked = AndroidIconPicker.Pick(new[] + { + "/p/res/mipmap-mdpi/app_icon.png", + "/p/res/mipmap-xxhdpi/app_icon.png", + "/p/res/mipmap-hdpi/app_icon.png", + "/p/res/mipmap-anydpi-v26/app_icon.xml", + "/p/res/mipmap-xxxhdpi/other.png", + }); + + Assert.That(picked, Is.EqualTo("/p/res/mipmap-xxhdpi/app_icon.png")); + } + + [Test] + public void Adaptive_xml_icons_and_other_names_are_not_sent() + { + Assert.That(AndroidIconPicker.Pick(new[] { "/p/res/mipmap-anydpi-v26/app_icon.xml", "/p/res/drawable/logo.png" }), Is.Null); + Assert.That(AndroidIconPicker.Pick(new string[0]), Is.Null); + } + + [Test] + public void An_unqualified_folder_ranks_below_any_density_and_windows_paths_work() + { + var picked = AndroidIconPicker.Pick(new[] { "C:\\p\\res\\mipmap\\app_icon.webp", "C:\\p\\res\\mipmap-hdpi\\app_icon.webp" }); + + Assert.That(picked, Is.EqualTo("C:\\p\\res\\mipmap-hdpi\\app_icon.webp")); + } + } +} diff --git a/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle new file mode 100644 index 0000000..fd43460 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/baseProjectTemplate.gradle @@ -0,0 +1,13 @@ +plugins { + // If you are changing the Android Gradle Plugin version, make sure it is compatible with the Gradle version preinstalled with Unity + // See which Gradle version is preinstalled with Unity here https://docs.unity3d.com/Manual/android-gradle-overview.html + // See official Gradle and Android Gradle Plugin compatibility table here https://developer.android.com/studio/releases/gradle-plugin#updating-gradle + // To specify a custom Gradle version in Unity, go do "Preferences > External Tools", uncheck "Gradle Installed with Unity (recommended)" and specify a path to a custom Gradle version + id 'com.android.application' version '7.4.2' apply false + id 'com.android.library' version '7.4.2' apply false + **BUILD_SCRIPT_DEPS** +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle new file mode 100644 index 0000000..204dd73 --- /dev/null +++ b/Tests~/WrapperPolicy/Fixtures/UnityTemplates/2022.3.62f3/launcherTemplate.gradle @@ -0,0 +1,62 @@ +apply plugin: 'com.android.application' + +dependencies { + implementation project(':unityLibrary') + } + +android { + namespace "**NAMESPACE**" + ndkPath "**NDKPATH**" + + compileSdkVersion **APIVERSION** + buildToolsVersion '**BUILDTOOLS**' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + defaultConfig { + minSdkVersion **MINSDKVERSION** + targetSdkVersion **TARGETSDKVERSION** + applicationId '**APPLICATIONID**' + ndk { + abiFilters **ABIFILTERS** + } + versionCode **VERSIONCODE** + versionName '**VERSIONNAME**' + } + + aaptOptions { + noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ') + ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~" + }**SIGN** + + lintOptions { + abortOnError false + } + + buildTypes { + debug { + minifyEnabled **MINIFY_DEBUG** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG** + jniDebuggable true + } + release { + minifyEnabled **MINIFY_RELEASE** + proguardFiles getDefaultProguardFile('proguard-android.txt')**SIGNCONFIG** + } + }**PACKAGING_OPTIONS****PLAY_ASSET_PACKS****SPLITS** +**BUILT_APK_LOCATION** + bundle { + language { + enableSplit = false + } + density { + enableSplit = false + } + abi { + enableSplit = true + } + } +}**SPLITS_VERSION_CODE****LAUNCHER_SOURCE_BUILD_SETUP** diff --git a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs new file mode 100644 index 0000000..f527ed8 --- /dev/null +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -0,0 +1,105 @@ +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class GradleBuildIdBlockTests + { + static string Template(string unity, string name) => + File.ReadAllText(Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "UnityTemplates", unity, name)); + + [TestCase("2021.3.45f1")] + [TestCase("2022.3.62f3")] + public void The_block_goes_before_the_android_block_after_the_application_plugin(string unity) + { + var launcher = Template(unity, "launcherTemplate.gradle"); + + var patched = GradleBuildIdBlock.Apply(launcher, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.Applied)); + int apply = patched.IndexOf("apply plugin: 'com.android.application'"); + int block = patched.IndexOf(GradleBuildIdBlock.Begin); + int end = patched.IndexOf(GradleBuildIdBlock.End); + int android = patched.IndexOf("\nandroid {"); + Assert.That(apply, Is.GreaterThanOrEqualTo(0)); + Assert.That(block, Is.GreaterThan(apply)); + Assert.That(end, Is.GreaterThan(block)); + Assert.That(android, Is.GreaterThan(end)); + Assert.That(patched.Replace(GradleBuildIdBlock.Snippet() + "\n\n", ""), Is.EqualTo(launcher), "only the block is added"); + } + + [Test] + public void Applying_twice_changes_nothing() + { + var once = GradleBuildIdBlock.Apply(Template("2022.3.62f3", "launcherTemplate.gradle"), out _); + + var twice = GradleBuildIdBlock.Apply(once, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.AlreadyCurrent)); + Assert.That(twice, Is.EqualTo(once)); + } + + [Test] + public void A_block_from_an_earlier_package_version_is_replaced_in_place() + { + var launcher = Template("2022.3.62f3", "launcherTemplate.gradle"); + var once = GradleBuildIdBlock.Apply(launcher, out _); + var stale = once.Replace("bugsee.build_id=", "bugsee.old_id="); + + var fixedUp = GradleBuildIdBlock.Apply(stale, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.Applied)); + Assert.That(fixedUp, Is.EqualTo(once)); + } + + [Test] + public void A_launcher_without_a_top_level_android_block_is_left_alone() + { + const string text = "apply plugin: 'com.android.application'\n// android {\n/* android {\n*/\ndependencies {\n android {\n }\n}\n"; + + var patched = GradleBuildIdBlock.Apply(text, out var status); + + Assert.That(status, Is.EqualTo(BuildIdBlockStatus.NoAnchor)); + Assert.That(patched, Is.EqualTo(text)); + } + + [Test] + public void Crlf_launchers_keep_their_line_endings() + { + var crlf = Template("2022.3.62f3", "launcherTemplate.gradle").Replace("\r\n", "\n").Replace("\n", "\r\n"); + + var patched = GradleBuildIdBlock.Apply(crlf, out _); + + Assert.That(patched.Replace("\r\n", ""), Does.Not.Contain("\n")); + } + + [Test] + public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4() + { + var snippet = GradleBuildIdBlock.Snippet(); + + Assert.That(snippet, Does.Contain("project.extensions.findByName('androidComponents') != null")); + Assert.That(snippet, Does.Contain("catch (Throwable t)")); + Assert.That(snippet, Does.Contain("variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.OBFUSCATION_MAPPING_FILE.INSTANCE)")); + Assert.That(snippet, Does.Not.Contain("toListenTo"), "AGP 7.4.2 has no toListenTo"); + Assert.That(snippet, Does.Not.Contain("toTransform"), "transforming ASSETS on AGP 7.4.2 drops every library asset, Unity's game data included"); + Assert.That(snippet, Does.Contain("variant.sources.assets.addGeneratedSourceDirectory(asset")); + Assert.That(snippet, Does.Contain("bugsee_build_id.properties")); + Assert.That(snippet, Does.Contain("bugsee.build_id=${id}")); + Assert.That(snippet.Split('\n').Count(l => l.StartsWith("import ")), Is.Zero); + } + + [TestCase("id 'com.android.application' version '7.4.2' apply false", true)] + [TestCase("id 'com.android.application' version '7.1.2' apply false", false)] + [TestCase("id 'com.android.application' version '8.3.0' apply false", true)] + [TestCase("id 'com.android.application' version '8.6.0' apply false", false)] + [TestCase("id 'com.android.application' version '9.0.0' apply false", false)] + [TestCase("classpath 'com.android.tools.build:gradle:4.0.1'", false)] + [TestCase("no agp here", false)] + public void The_block_is_for_agp_7_4_up_to_the_plugins_own_minimum(string root, bool expected) + { + Assert.That(GradleTemplateDefaults.AgpSupportsBuildIdBlock(root), Is.EqualTo(expected)); + } + } +} diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs new file mode 100644 index 0000000..5e101a4 --- /dev/null +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + /// + /// The Editor scripts are compiled only by Unity, so the wiring the policy classes depend on is checked in source: + /// the upload resolves its CLI through the locator, and a mapping is keyed by the SDK id when no build id asset + /// runs (Unity 2021.3 / 2022.3). + /// + public class OldUnitySymbolUploadSourceTests + { + static string Source(string relative) + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return File.ReadAllText(Path.Combine(dir, relative)); + dir = Directory.GetParent(dir)?.FullName; + } + + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0), "Missing: " + signature); + int brace = source.IndexOf('{', start); + int depth = 0; + for (int i = brace; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) + return source.Substring(start, i - start + 1); + } + + Assert.Fail("Unclosed body: " + signature); + return null; + } + + [Test] + public void The_runner_starts_the_resolved_binary_and_never_the_bare_name() + { + var source = Source("Editor/BugseeCliRunner.cs"); + + Assert.That(Body(source, "public static string CliPath"), Does.Contain("BugseeCliLocator.Resolve()")); + Assert.That(Body(source, "public static int Run("), Does.Contain("BugseeCliLocator.ReportIfMissing()")); + Assert.That(source, Does.Not.Contain("?? \"bugsee-cli\"")); + } + + [Test] + public void The_locator_downloads_only_over_https_and_checks_the_published_checksum() + { + var body = Body(Source("Editor/BugseeCliLocator.cs"), "static string Download("); + + Assert.That(body, Does.Contain("StartsWith(\"https://\"")); + Assert.That(body, Does.Contain("BugseeCliPolicy.ParseSha256Sidecar(")); + Assert.That(body, Does.Contain("Sha256Hex(archive)")); + Assert.That(body, Does.Contain("SHA-256 mismatch")); + } + + [Test] + public void The_locator_forgets_a_failed_lookup_when_the_next_build_starts() + { + var source = Source("Editor/BugseeCliLocator.cs"); + + Assert.That(source, Does.Contain("class BugseeCliResolutionReset : IPreprocessBuildWithReport")); + Assert.That(source, Does.Contain("public void OnPreprocessBuild(BuildReport report) => BugseeCliLocator.Reset();")); + } + + [Test] + public void The_mapping_upload_carries_the_sdk_build_id_unless_the_gradle_plugin_runs() + { + var body = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadAndroidSymbols("); + + Assert.That(body, Does.Contain("if (!BugseeAndroidGradleSetup.BuildIdAssetInjected)")); + Assert.That(body, Does.Contain("mappingExtra.Add(\"--uuid\")")); + Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveBuildUuid(report)")); + Assert.That(body, Does.Contain("mappingExtra.Add(\"--icon\")"), "the plugin attaches the launcher icon to the mapping"); + Assert.That(Body(Source("Editor/BugseeSymbolUpload.cs"), "static string FindLauncherIcon("), Does.Contain("AndroidIconPicker.Pick(")); + } + + [Test] + public void The_android_build_id_is_the_one_the_sdk_derives_from_the_app_as_built() + { + var body = Body(Source("Editor/BugseeBuildIdentity.cs"), "public static string ResolveBuildUuid("); + + Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); + Assert.That(body, Does.Contain("names.BundleVersion")); + Assert.That(body, Does.Contain("names.VersionCode")); + } + + [Test] + public void The_gradle_setup_records_whether_the_build_id_asset_was_added_for_this_build_only() + { + var source = Source("Editor/BugseeAndroidGradleSetup.cs"); + + Assert.That(Body(source, "public void OnPreprocessBuild("), Does.Contain("BuildIdAssetInjected = false")); + Assert.That(Body(source, "static void PatchGeneratedProject("), Does.Contain("BuildIdAssetInjected = pluginPresent || blockPresent")); + } + } +} From fc209836d6f56683f215473bee40f937c090001e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:08:55 +0500 Subject: [PATCH 150/157] Validate BUGSEE_CLI_VERSION before it reaches the download URL and cache path. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeCliLocator.cs | 9 ++++++++- Runtime/WrapperPolicy/BugseeCliPolicy.cs | 8 ++++++++ Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 13 +++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs index 61f10a2..c589a61 100644 --- a/Editor/BugseeCliLocator.cs +++ b/Editor/BugseeCliLocator.cs @@ -112,6 +112,13 @@ static CliSettings ReadSettings() string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string localAppData = IsWindows ? Environment.GetEnvironmentVariable("LOCALAPPDATA") : null; string version = Environment.GetEnvironmentVariable("BUGSEE_CLI_VERSION"); + version = string.IsNullOrWhiteSpace(version) ? null : version.Trim().TrimStart('v'); + if (version != null && !BugseeCliPolicy.IsValidVersion(version)) + { + Debug.LogWarning("[Bugsee] Ignoring BUGSEE_CLI_VERSION=" + version + ": not a release version like 0.8.1. Using " + BugseeCliPolicy.DefaultVersion + "."); + version = null; + } + var settings = new CliSettings { ExplicitPath = Environment.GetEnvironmentVariable("BUGSEE_CLI_PATH"), @@ -119,7 +126,7 @@ static CliSettings ReadSettings() Home = home, LocalAppData = localAppData, IsWindows = IsWindows, - Version = string.IsNullOrWhiteSpace(version) ? BugseeCliPolicy.DefaultVersion : version.Trim().TrimStart('v'), + Version = version ?? BugseeCliPolicy.DefaultVersion, Triple = BugseeCliPolicy.HostTriple(HostOs, RuntimeInformation.OSArchitecture.ToString()), DownloadAllowed = BugseeCliPolicy.DownloadAllowed( BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs index 7bae6c7..95f0844 100644 --- a/Runtime/WrapperPolicy/BugseeCliPolicy.cs +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -102,6 +102,14 @@ public static string HostTriple(string os, string arch) return null; } + /// + /// A release version safe to put in a URL and a cache path: digits and dots with an optional "-"/"+" suffix. + /// BUGSEE_CLI_VERSION is validated with this so a value like "../.." cannot leave the cache directory. + /// + public static bool IsValidVersion(string version) => + version != null && System.Text.RegularExpressions.Regex.IsMatch( + version, @"^[0-9]+(\.[0-9]+)*([-+][0-9A-Za-z.]+)?$"); + public static bool IsWindowsTriple(string triple) => triple != null && triple.Contains("windows"); public static string BinaryName(bool windows) => windows ? CliName + ".exe" : CliName; diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs index 022c026..489bbe7 100644 --- a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -282,6 +282,19 @@ public void Download_needs_the_token_and_respects_the_opt_out(bool token, bool o Assert.That(BugseeCliPolicy.DownloadAllowed(token, optedOut), Is.EqualTo(expected)); } + [TestCase("0.8.1", true)] + [TestCase("1.2", true)] + [TestCase("0.9.0-rc.1", true)] + [TestCase("../..", false)] + [TestCase("0.8.1/../../x", false)] + [TestCase("", false)] + [TestCase("v0.8.1", false)] + [TestCase(null, false)] + public void Only_release_versions_may_reach_a_path_or_url(string version, bool expected) + { + Assert.That(BugseeCliPolicy.IsValidVersion(version), Is.EqualTo(expected)); + } + [Test] public void Cache_layout_is_version_then_triple() { From 15dce471d81898461c92256a065db9b16786dff7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:22:50 +0500 Subject: [PATCH 151/157] Skip an empty R8 mapping instead of uploading it under the CLI's content id. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Editor/BugseeSymbolUpload.cs | 13 +++++++++++++ .../OldUnitySymbolUploadSourceTests.cs | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 92dafdc..b72649e 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -233,6 +233,13 @@ static void UploadIfFound( { continue; } + // R8 writes an empty mapping when nothing was obfuscated. The Gradle plugin (and the build id + // block) write no id for it, so the app reports the SDK's own id and an upload keyed by the CLI's + // content id could never match: skip it. + if (IsEmptyFile(file)) + { + continue; + } var fileRank = rank(file); if (fileRank == BuildArtifactChoice.Reject) { @@ -385,6 +392,12 @@ static List CollectRoots(BuildReport report) return roots; } + static bool IsEmptyFile(string path) + { + try { return new FileInfo(path).Length == 0; } + catch { return false; } + } + static bool IsExcludedPath(string path) { var norm = path.Replace('\\', '/'); diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index 5e101a4..c8dd506 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -83,6 +83,15 @@ public void The_mapping_upload_carries_the_sdk_build_id_unless_the_gradle_plugin Assert.That(Body(Source("Editor/BugseeSymbolUpload.cs"), "static string FindLauncherIcon("), Does.Contain("AndroidIconPicker.Pick(")); } + [Test] + public void An_empty_mapping_is_never_uploaded() + { + var source = Source("Editor/BugseeSymbolUpload.cs"); + + Assert.That(Body(source, "static void UploadIfFound("), Does.Contain("if (IsEmptyFile(file))")); + Assert.That(Body(source, "static bool IsEmptyFile("), Does.Contain("new FileInfo(path).Length == 0")); + } + [Test] public void The_android_build_id_is_the_one_the_sdk_derives_from_the_app_as_built() { From 8397419ababa8285b4949f288f164834ac381a70 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:58:08 +0500 Subject: [PATCH 152/157] Read the build id back from the built app, harden the CLI download and install, narrow the Gradle block to AGP 7.4.x. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: the upload id comes from the APK/AAB asset (else the SDK's synthesized id with a warning) instead of from whether the Gradle block was added; the same id keys the ELF upload; one bounded download with pinned SHA-256 for 0.8.1, atomic cache install, no curl retry after an HTTP error, failure remembered per Editor session; no Task.project at execution time; version/code read from the generated launcher; ArgumentList; .exe lookup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 4 +- Documentation~/index.md | 22 +- Editor/BugseeAndroidGradleSetup.cs | 28 +- Editor/BugseeBuildIdentity.cs | 63 +++-- Editor/BugseeCliLocator.cs | 251 +++++++++++++----- Editor/BugseeCliRunner.cs | 14 +- Editor/BugseeSymbolUpload.cs | 44 ++- .../WrapperPolicy/AndroidUploadIdentity.cs | 124 +++++++++ .../AndroidUploadIdentity.cs.meta | 11 + Runtime/WrapperPolicy/BugseeCliPolicy.cs | 96 +++++-- Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 27 +- .../WrapperPolicy/GradleTemplateDefaults.cs | 26 +- .../AndroidUploadIdentityTests.cs | 97 +++++++ Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 91 ++++++- .../WrapperPolicy/GradleBuildIdBlockTests.cs | 23 +- .../OldUnitySymbolUploadSourceTests.cs | 69 ++--- 16 files changed, 760 insertions(+), 230 deletions(-) create mode 100644 Runtime/WrapperPolicy/AndroidUploadIdentity.cs create mode 100644 Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta create mode 100644 Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a52695..ddaa9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a SHA-256-verified download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set (`BUGSEE_NO_CLI_DOWNLOAD=1` opts out, `BUGSEE_CLI_VERSION` and `BUGSEE_CLI_BASE_URL` override). A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered: the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute), so the mapping upload matches what the SDK reports and Java frames of minified builds deobfuscate. If the block cannot be added, or R8 is off, the SDK derives its own id from token, version name and version code and the mapping is uploaded under that id. The mapping upload now also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and verification in `Documentation~/index.md`. +- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (else the file the block writes under `launcher/build/bugsee/`) and uploads the mapping and the native symbols under it, with `--uuid` always explicit. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. diff --git a/Documentation~/index.md b/Documentation~/index.md index 8e78745..d4ca70e 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -161,14 +161,30 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle | Gradle plugin function | Unity 6 | Unity 2021.3 / 2022.3 | |---|---|---| | Declare `bugsee-android`, `bugsee-android-ndk` | Package (Maven injection) | Same | -| `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`); needs AGP 7.4 to 8.5. With R8 off no asset is written and the SDK derives its own id | -| `com.bugsee.android.BUILD_UUID` manifest meta-data (fallback id when R8 is off) | Plugin | Not added: the SDK derives `UUID(token, versionName, versionCode)` itself when it finds neither; the uploads use that id (`AndroidBuildUuid`) | -| Mapping upload (`--uuid`, `--icon`) | Package Editor upload (the plugin finds no app token in a Unity build, so its own upload skips) | Same: mapping, same id as the asset, launcher icon attached | +| `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`), for AGP 7.4.x only (the one line it was run on; other AGPs below 8.6 get no block). With R8 off, or no block, no asset is written and the SDK derives its own id | +| `com.bugsee.android.BUILD_UUID` manifest meta-data (fallback id when R8 is off) | Plugin | Not added: the SDK derives `UUID(token, versionName, versionCode)` itself when it finds neither; the uploads use that id (`AndroidBuildUuid`) when the built app has no asset | +| Mapping upload (`--uuid`, `--icon`) | Package Editor upload (the plugin finds no app token in a Unity build, so its own upload skips) | Same, keyed by the id read back from the built APK/AAB's asset (never assumed from the build script), else the SDK's synthesized id with a warning; launcher icon attached | | Native symbol upload | Package Editor upload of Unity's `symbols.zip` (same reason) | Same | | NDK enabled | Plugin | Not applicable: the NDK artifact is declared and the Editor uploads the symbols | | Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | | Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | +The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; split-APK version-code schemes and `versionNameSuffix` set outside Unity can still differ: set `BUGSEE_BUILD_UUID` then). `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding (and removes an earlier) asset block, to use the derived id only. With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. + +Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, uploads to UNITYTA; the generated launcher's Gradle ran AGP 7.4.2 / Gradle 7.5.1 on both): + +| Unity | Build | R8 | Result | +|---|---|---|---| +| 2022.3.62f3 | release | off | builds; no asset (nothing to match), game assets intact (24 `assets/bin` entries), no mapping uploaded, native symbols uploaded | +| 2022.3.62f3 | development | off | same | +| 2022.3.62f3 | development | on | builds; APK asset id equals the uploaded mapping id | +| 2022.3.62f3 | release | on | same, plus emulator native, Java and managed crashes symbolicated | +| 2022.3.62f3 | release | on, `BUGSEE_NO_GRADLE_BUILD_ID=1` | builds; no asset; mapping uploaded under the synthesized id with the warning; the Java crash on an emulator grouped with the symbolicated issue | +| 2021.3.45f1 | release | off, development off | builds, as above | +| 2021.3.45f1 | release, development | on | builds; APK asset id equals the uploaded mapping id | + +Not verified: AGP 7.5 to 8.5 (no block is added there by design), Unity 2021.3 releases with AGP below 7.4, AAB output, build with Gradle configuration cache (the block avoids `Task.project` at execution time but was run without it), and Windows/Linux Editors. + Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. If you do minify, add keep rules for the Unity wrapper classes: the package's `bugsee-unity-wrapper.proguard` is not applied by Unity unless you enable Player Settings > Publishing Settings > Custom Proguard File and copy the rules into `Assets/Plugins/Android/proguard-user.txt`, and that file does not yet cover `com.bugsee.unity.UnityManagedException` or `UnityWrapperIdentity` (R8 removes them: `ClassNotFoundException` at launch or on the first managed exception). **iOS** diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index 4a90105..f2e1898 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -33,14 +33,6 @@ sealed class BugseeAndroidGradleSetup : IPreprocessBuildWithReport, IPostGenerat const string PrefForcePlugin = "Bugsee.Android.ForceGradlePlugin"; - /// - /// Whether the current Android build writes the mapping-derived build id into the app, either through the Bugsee - /// Gradle plugin or through (AGP too old for the plugin). Then the CLI derives - /// the same id from the mapping on its own. Otherwise the SDK derives the id from token, version name and - /// code, and the uploads must be keyed by that (). - /// - internal static bool BuildIdAssetInjected { get; private set; } - /// The generated Gradle project of the current Android build (the folder with settings.gradle), or null. internal static string GeneratedProjectRoot { get; private set; } @@ -48,7 +40,6 @@ public void OnPreprocessBuild(BuildReport report) { if (report.summary.platform == BuildTarget.Android) { - BuildIdAssetInjected = false; GeneratedProjectRoot = null; RemoveLegacyTemplates(); } @@ -119,18 +110,19 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } - bool pluginPresent = apply && - patchedRoot.Contains(GradlePluginMarker) && patchedLauncher.Contains(GradlePluginMarker); - bool blockPresent = false; - if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") != null) + { + // Opt-out (the Unity version regenerates only what changed, so a block from an earlier build is removed). + patchedLauncher = GradleBuildIdBlock.Remove(patchedLauncher); + } + else if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) { patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); - blockPresent = blockStatus != BuildIdBlockStatus.NoAnchor; - if (blockPresent) + if (blockStatus != BuildIdBlockStatus.NoAnchor) { Debug.Log( - "[Bugsee] Added the build id asset (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + - ": the part of the Bugsee Gradle plugin that works on this AGP."); + "[Bugsee] Added the build id asset task (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + + ": the part of the Bugsee Gradle plugin that works on this AGP. The upload uses whatever id the built app carries."); } else { @@ -140,8 +132,6 @@ static void PatchGeneratedProject(string projectRoot) } } - BuildIdAssetInjected = pluginPresent || blockPresent; - if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { File.WriteAllText(rootPath, patchedRoot); diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 957f1af..1cb4605 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -67,31 +67,64 @@ public static string ResolveBuildNumber(BuildReport report) } /// - /// BUILD_UUID the symbol uploads are keyed by (bugsee-cli --uuid): BUGSEE_BUILD_UUID, else on Android the - /// id the SDK derives itself for an app the Gradle plugin did not build (: token, - /// version name and version code as the APK carries them, not BUGSEE_VERSION/BUGSEE_BUILD), else a random one. Per-.so GNU build-ids remain the real native symbol keys. + /// The build id an Android symbol upload is keyed by: the one the built app reports. Read back from what Gradle + /// produced (the APK/AAB's assets/bugsee_build_id.properties), with + /// the id the SDK derives itself () as the fallback, see + /// . BUGSEE_BUILD_UUID overrides. Per-.so GNU build-ids remain the real native + /// symbol keys; this id is what ties a mapping to a session (and groups the ELF upload with it). /// - public static string ResolveBuildUuid(BuildReport report) + public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) { - var env = Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"); - if (!string.IsNullOrEmpty(env)) + var names = AndroidNameSettings(report); + string name = names.BundleVersion; + string code = names.VersionCode.ToString(); + // What the APK carries, when the generated launcher says so plainly (split APK codes, overrides). + var root = BugseeAndroidGradleSetup.GeneratedProjectRoot; + var launcher = string.IsNullOrEmpty(root) ? null : Path.Combine(root, "launcher", "build.gradle"); + if (launcher != null && File.Exists(launcher)) { - return env.Trim(); + GradleTemplateDefaults.ReadGradleVersion(File.ReadAllText(launcher), out var gradleName, out var gradleCode); + name = gradleName ?? name; + code = gradleCode ?? code; } - if (report?.summary.platform == BuildTarget.Android) + return AndroidUploadIdentity.Decide( + Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"), + ReadAppAssetId(report?.summary.outputPath), + AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code)); + } + + /// The id in the APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). + static string ReadAppAssetId(string outputPath) + { + try { - var names = AndroidNameSettings(report); - var synthesized = AndroidBuildUuid.Synthesize( - BugseeCliRunner.AppToken, names.BundleVersion, names.VersionCode.ToString()); - if (synthesized != null) + if (string.IsNullOrEmpty(outputPath) || !File.Exists(outputPath)) { - return synthesized; + return null; } + + using (var zip = System.IO.Compression.ZipFile.OpenRead(outputPath)) + { + foreach (var entry in zip.Entries) + { + if (entry.FullName == "assets/bugsee_build_id.properties" || + entry.FullName == "base/assets/bugsee_build_id.properties") + { + using (var reader = new StreamReader(entry.Open())) + { + return AndroidUploadIdentity.ParseAssetProperties(reader.ReadToEnd()); + } + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: could not read the build id asset from {outputPath}: {ex.Message}"); } - // Prefer a stable dashed UUID when nothing above applies. - return Guid.NewGuid().ToString("D"); + return null; } /// diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs index c589a61..cbf1c26 100644 --- a/Editor/BugseeCliLocator.cs +++ b/Editor/BugseeCliLocator.cs @@ -27,6 +27,9 @@ internal static class BugseeCliLocator { static CliResolution _resolved; static bool _notFoundReported; + // Kept for the editor session (Reset does not clear it): a blackholed network must cost one timeout, not one per build. + static string _downloadFailure; + static string _stagingDir; public static CliResolution Resolve() { @@ -42,7 +45,9 @@ public static CliResolution Resolve() { FileExists = File.Exists, QueryVersion = QueryVersion, - Download = (cacheDir, version) => Download(settings, cacheDir, version), + Fetch = version => Fetch(settings, version), + Install = (staged, cacheDir, version) => Install(settings, staged, cacheDir, version), + Discard = _ => DiscardStaging(), }); } catch (Exception ex) @@ -118,6 +123,11 @@ static CliSettings ReadSettings() Debug.LogWarning("[Bugsee] Ignoring BUGSEE_CLI_VERSION=" + version + ": not a release version like 0.8.1. Using " + BugseeCliPolicy.DefaultVersion + "."); version = null; } + else if (version != null && !BugseeCliPolicy.VersionAtLeast(version, BugseeCliPolicy.MinVersion)) + { + Debug.LogWarning("[Bugsee] Ignoring BUGSEE_CLI_VERSION=" + version + ": older than " + BugseeCliPolicy.MinVersion + ", which the uploads need. Using " + BugseeCliPolicy.DefaultVersion + "."); + version = null; + } var settings = new CliSettings { @@ -131,6 +141,11 @@ static CliSettings ReadSettings() DownloadAllowed = BugseeCliPolicy.DownloadAllowed( BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), }; + if (_downloadFailure != null) + { + settings.DownloadAllowed = false; + settings.DownloadDisabledNote = "Downloading bugsee-cli failed earlier in this Editor session (" + _downloadFailure + "); not retried until it restarts."; + } // Shared by every project of this user first; the project's own Library when that is not writable. string userCache = null; @@ -161,12 +176,12 @@ static CliSettings ReadSettings() static string QueryVersion(string path) { string output; - int exit = Capture(path, "--version", 15000, out output); + int exit = Capture(path, new[] { "--version" }, 15000, out output); return exit == 0 ? BugseeCliPolicy.ParseVersionOutput(output) : null; } /// Runs a process, returns its exit code (-1 when it did not start or run to the end) and stdout. - static int Capture(string file, string args, int timeoutMs, out string stdout) + static int Capture(string file, string[] args, int timeoutMs, out string stdout) { stdout = null; try @@ -174,12 +189,16 @@ static int Capture(string file, string args, int timeoutMs, out string stdout) var psi = new ProcessStartInfo { FileName = file, - Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; + foreach (string arg in args) + { + psi.ArgumentList.Add(arg); + } + using (Process proc = Process.Start(psi)) { if (proc == null) @@ -214,13 +233,25 @@ static int Capture(string file, string args, int timeoutMs, out string stdout) } } - static string Download(CliSettings settings, string cacheDir, string version) + /// Downloads, verifies and unpacks the release once into a temp directory; returns the binary there. + static string Fetch(CliSettings settings, string version) + { + try + { + return FetchUnchecked(settings, version); + } + catch (Exception ex) + { + _downloadFailure = ex.Message; + DiscardStaging(); + throw; + } + } + + static string FetchUnchecked(CliSettings settings, string version) { string triple = settings.Triple; string binaryName = BugseeCliPolicy.BinaryName(BugseeCliPolicy.IsWindowsTriple(triple)); - string finalDir = Path.GetDirectoryName(BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, triple)); - string finalBinary = Path.Combine(finalDir, binaryName); - string baseUrl = Environment.GetEnvironmentVariable("BUGSEE_CLI_BASE_URL"); string url = BugseeCliPolicy.ArchiveUrl(baseUrl, version, triple); if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) @@ -228,88 +259,132 @@ static string Download(CliSettings settings, string cacheDir, string version) throw new IOException("refusing a non-https download URL " + url); } - string work = Path.Combine(Path.GetDirectoryName(finalDir), triple + ".part-" + Guid.NewGuid().ToString("N").Substring(0, 8)); - Directory.CreateDirectory(work); - try + _stagingDir = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(_stagingDir); + Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); + string archive = Path.Combine(_stagingDir, BugseeCliPolicy.ArchiveName(triple)); + FetchFile(url, archive); + + // The pinned hash for the default release; the published sidecar for any other. + string expected = BugseeCliPolicy.KnownSha256(version, triple); + if (expected == null) { - Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); - string archive = Path.Combine(work, BugseeCliPolicy.ArchiveName(triple)); - Fetch(url, archive); - string expected = BugseeCliPolicy.ParseSha256Sidecar(FetchText(BugseeCliPolicy.ChecksumUrl(url))); + string sidecar = Path.Combine(_stagingDir, "archive.sha256"); + FetchFile(BugseeCliPolicy.ChecksumUrl(url), sidecar); + expected = BugseeCliPolicy.ParseSha256Sidecar(File.ReadAllText(sidecar)); if (expected == null) { throw new IOException("the published checksum for " + Path.GetFileName(archive) + " is malformed"); } + } - string actual = Sha256Hex(archive); - if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) - { - throw new IOException("SHA-256 mismatch for " + Path.GetFileName(archive) + ": expected " + expected + ", got " + actual); - } + string actual = Sha256Hex(archive); + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("SHA-256 mismatch for " + Path.GetFileName(archive) + ": expected " + expected + ", got " + actual); + } - string extracted = Path.Combine(work, "x"); - Directory.CreateDirectory(extracted); - Extract(archive, extracted, BugseeCliPolicy.IsWindowsTriple(triple)); - string binary = FindBinary(extracted, binaryName); - if (binary == null) - { - throw new IOException("the archive holds no " + binaryName); - } + string extracted = Path.Combine(_stagingDir, "x"); + Directory.CreateDirectory(extracted); + Extract(archive, extracted, BugseeCliPolicy.IsWindowsTriple(triple)); + string binary = FindBinary(extracted, binaryName); + if (binary == null) + { + throw new IOException("the archive holds no " + binaryName); + } - if (!BugseeCliPolicy.IsWindowsTriple(triple)) - { - int chmod = Capture("chmod", "+x \"" + binary + "\"", 15000, out _); - if (chmod != 0) - { - throw new IOException("chmod +x failed on " + binary); - } - } + if (!BugseeCliPolicy.IsWindowsTriple(triple) && Capture("chmod", new[] { "+x", binary }, 15000, out _) != 0) + { + throw new IOException("chmod +x failed on " + binary); + } + + if (QueryVersion(binary) == null) + { + throw new IOException("the downloaded " + binaryName + " does not run on this machine"); + } + + return binary; + } - // A concurrent build may have installed the same release meanwhile: keep theirs. - Directory.CreateDirectory(finalDir); - if (!File.Exists(finalBinary)) + /// + /// Puts the staged binary into the cache atomically: copied to a temp name next to the target, then moved. A + /// binary another build installed meanwhile is kept; only files created by this call are ever deleted. + /// + static string Install(CliSettings settings, string staged, string cacheDir, string version) + { + string finalBinary = BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, settings.Triple); + string finalDir = Path.GetDirectoryName(finalBinary); + Directory.CreateDirectory(finalDir); + if (File.Exists(finalBinary) && QueryVersion(finalBinary) != null) + { + return finalBinary; + } + + string temp = Path.Combine(finalDir, Path.GetFileName(finalBinary) + ".tmp-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + try + { + File.Copy(staged, temp, false); + if (!BugseeCliPolicy.IsWindowsTriple(settings.Triple) && Capture("chmod", new[] { "+x", temp }, 15000, out _) != 0) { - File.Copy(binary, finalBinary, true); + throw new IOException("chmod +x failed on " + temp); } - if (!BugseeCliPolicy.IsWindowsTriple(triple)) + try { - Capture("chmod", "+x \"" + finalBinary + "\"", 15000, out _); + File.Move(temp, finalBinary); } - - if (QueryVersion(finalBinary) == null) + catch (IOException) { - try { File.Delete(finalBinary); } catch { /* ignore */ } - throw new IOException("the downloaded " + binaryName + " does not run on this machine"); + // Another build won the race; theirs is kept if it runs. + if (!(File.Exists(finalBinary) && QueryVersion(finalBinary) != null)) + { + throw; + } } return finalBinary; } finally { - try { Directory.Delete(work, true); } catch { /* ignore */ } + try { if (File.Exists(temp)) File.Delete(temp); } catch { /* ignore */ } } } - static void Fetch(string url, string destination) + static void DiscardStaging() + { + string dir = _stagingDir; + _stagingDir = null; + if (dir != null) + { + try { Directory.Delete(dir, true); } catch { /* ignore */ } + } + } + + /// An HTTP error status: the server answered, so a second attempt with curl would get the same answer. + sealed class HttpStatusException : IOException + { + public HttpStatusException(string message) : base(message) { } + } + + static void FetchFile(string url, string destination) { try { - var request = (HttpWebRequest)WebRequest.Create(url); - request.Timeout = 30000; - request.ReadWriteTimeout = 120000; - using (var response = (HttpWebResponse)request.GetResponse()) - using (Stream input = response.GetResponseStream()) - using (var output = File.Create(destination)) - { - input.CopyTo(output); - } + FetchWithRedirects(url, destination); + } + catch (HttpStatusException) + { + throw; } catch (Exception ex) { - // Mono's TLS stack rejects some hosts' certificate chains: curl uses the system trust store. + // Mono's TLS stack rejects some hosts' certificate chains and has its own timeouts: curl uses the system + // trust store. Bounded, https only (also across redirects). string ignored; - int exit = Capture("curl", "--proto \"=https\" --tlsv1.2 -fsSL --max-time 300 -o \"" + destination + "\" \"" + url + "\"", 330000, out ignored); + int exit = Capture( + "curl", + new[] { "--proto", "=https", "--proto-redir", "=https", "--tlsv1.2", "-fsSL", "--connect-timeout", "10", "--max-time", "120", "-o", destination, url }, + 130000, out ignored); if (exit != 0 || !File.Exists(destination)) { throw new IOException("GET " + url + " failed (" + ex.Message + "; curl exit " + exit + ")"); @@ -317,18 +392,56 @@ static void Fetch(string url, string destination) } } - static string FetchText(string url) + static void FetchWithRedirects(string url, string destination) { - string temp = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8) + ".sha256"); - try + string current = url; + for (int hop = 0; hop <= 5; hop++) { - Fetch(url, temp); - return File.ReadAllText(temp); - } - finally - { - try { File.Delete(temp); } catch { /* ignore */ } + var request = (HttpWebRequest)WebRequest.Create(current); + request.Timeout = 20000; + request.ReadWriteTimeout = 60000; + request.AllowAutoRedirect = false; + HttpWebResponse response; + try + { + response = (HttpWebResponse)request.GetResponse(); + } + catch (WebException ex) when (ex.Response is HttpWebResponse errorResponse) + { + response = errorResponse; + } + + using (response) + { + int status = (int)response.StatusCode; + if (status >= 300 && status < 400) + { + var next = new Uri(new Uri(current), response.Headers["Location"] ?? ""); + if (next.Scheme != Uri.UriSchemeHttps) + { + throw new HttpStatusException("refusing a redirect to " + next + " (not https)"); + } + + current = next.AbsoluteUri; + continue; + } + + if (status < 200 || status >= 300) + { + throw new HttpStatusException("GET " + current + " returned " + status); + } + + using (Stream input = response.GetResponseStream()) + using (var output = File.Create(destination)) + { + input.CopyTo(output); + } + + return; + } } + + throw new HttpStatusException("too many redirects from " + url); } static string Sha256Hex(string path) @@ -355,7 +468,7 @@ static void Extract(string archive, string destination, bool zip) } string ignored; - int exit = Capture("tar", "-xf \"" + archive + "\" -C \"" + destination + "\"", 120000, out ignored); + int exit = Capture("tar", new[] { "-xf", archive, "-C", destination }, 120000, out ignored); if (exit != 0) { throw new IOException("tar -xf " + Path.GetFileName(archive) + " failed (exit " + exit + "); xz support is needed to unpack it"); diff --git a/Editor/BugseeCliRunner.cs b/Editor/BugseeCliRunner.cs index d73f0bc..3cb61c7 100644 --- a/Editor/BugseeCliRunner.cs +++ b/Editor/BugseeCliRunner.cs @@ -46,22 +46,20 @@ public static int Run(IList args, int timeoutMs = DefaultTimeoutMs) return -1; } - var quoted = new string[args.Count]; - for (var i = 0; i < args.Count; i++) - { - quoted[i] = Quote(args[i] ?? ""); - } - var psi = new ProcessStartInfo { FileName = CliPath, - Arguments = string.Join(" ", quoted), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; + foreach (var arg in args) + { + psi.ArgumentList.Add(arg ?? ""); + } + var token = AppToken; if (!string.IsNullOrEmpty(token)) { @@ -129,8 +127,6 @@ public static int Run(IList args, int timeoutMs = DefaultTimeoutMs) return -1; } } - - public static string Quote(string s) => $"\"{(s ?? "").Replace("\"", "\\\"")}\""; } } #endif diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index b72649e..9c2fae3 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -74,30 +74,24 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. var stem = OutputStem(report); var allowGeneric = OutputIsDirectory(report); - // The Gradle plugin (or its stand-in, GradleBuildIdBlock) writes the mapping's content id into the app, and the - // CLI derives the same id on its own. Without either the SDK reports an id built from token, version name - // and code, so the mapping is uploaded under that one or its Java frames would never be deobfuscated. - var mappingExtra = new List(); - if (!BugseeAndroidGradleSetup.BuildIdAssetInjected) - { - var buildUuid = BugseeBuildIdentity.ResolveBuildUuid(report); - mappingExtra.Add("--uuid"); - mappingExtra.Add(buildUuid); - } + // Keyed by the id the built app reports (read back from the APK or Gradle's output, see + // BugseeBuildIdentity.ResolveAndroidUploadIdentity), never assumed from what the build script contained. + var identity = BugseeBuildIdentity.ResolveAndroidUploadIdentity(report); + // Said only when a mapping is actually uploaded under it (R8 off: nothing to match). + var identityWarning = identity.Warning != null && identity.Source != BuildIdSource.None ? "Bugsee: " + identity.Warning : null; // The Gradle plugin attaches the launcher icon to the mapping (the app's icon in the dashboard). var icon = FindLauncherIcon(); if (icon != null) { Debug.Log($"Bugsee: attaching launcher icon {icon} to the mapping upload."); - mappingExtra.Add("--icon"); - mappingExtra.Add(icon); } + var mappingExtra = AndroidUploadIdentity.MappingArguments(identity.Uuid, icon); UploadIfFound(roots, "*mapping.txt", "proguard", version, build, - path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra); + path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra, identityWarning); // Unity "Create symbols.zip" / Gradle native debug symbols. - UploadSymbolsZipOrElf(report, roots, version, build); + UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid ?? Guid.NewGuid().ToString("D")); } static string FindLauncherIcon() @@ -120,7 +114,7 @@ static string FindLauncherIcon() } } - static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build) + static void UploadSymbolsZipOrElf(BuildReport report, List roots, string version, string build, string buildUuid) { var bestZip = FindSymbolsZip(report); var elfDirs = new List(); @@ -158,7 +152,6 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string // bugsee-cli --type elf accepts a zip of ELF objects (Unity symbols.zip layout). // --uuid is the BUILD_UUID correlation key (required by CLI); each .so is still // keyed by its own GNU build-id. - var buildUuid = BugseeBuildIdentity.ResolveBuildUuid(report); Debug.Log($"Bugsee: uploading Android symbols zip via {BugseeCliRunner.CliPath}: {bestZip}"); var args = new List { @@ -167,11 +160,8 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string "--version", version, "--build", build, }; - if (!string.IsNullOrEmpty(buildUuid)) - { - args.Add("--uuid"); - args.Add(buildUuid); - } + args.Add("--uuid"); + args.Add(buildUuid); var exit = BugseeCliRunner.Run(args); if (exit != 0) { @@ -214,7 +204,8 @@ static void UploadIfFound( string version, string build, Func rank, - IList extraArgs = null) + IList extraArgs = null, + string warnWhenFound = null) { string best = null; var bestRank = BuildArtifactChoice.Reject; @@ -233,9 +224,7 @@ static void UploadIfFound( { continue; } - // R8 writes an empty mapping when nothing was obfuscated. The Gradle plugin (and the build id - // block) write no id for it, so the app reports the SDK's own id and an upload keyed by the CLI's - // content id could never match: skip it. + // R8 writes an empty mapping when nothing was obfuscated: there is nothing to deobfuscate. if (IsEmptyFile(file)) { continue; @@ -264,6 +253,11 @@ static void UploadIfFound( return; } + if (warnWhenFound != null) + { + Debug.LogWarning(warnWhenFound); + } + Debug.Log($"Bugsee: uploading {type} via {BugseeCliRunner.CliPath}: {best}"); var args = new List { diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs new file mode 100644 index 0000000..656928b --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Bugsee.WrapperPolicy +{ + public enum BuildIdSource + { + /// BUGSEE_BUILD_UUID. + Environment, + /// assets/bugsee_build_id.properties read from the built APK/AAB: what the app reports. + AppAsset, + /// What the SDK derives itself when the app has no asset: . + Synthesized, + /// Nothing known (no token): a random id, keyed by nothing the app can report. + None, + } + + public sealed class UploadIdentity + { + public string Uuid; + public BuildIdSource Source; + /// Why the id may not match what the app reports; shown when a mapping is uploaded under it. + public string Warning; + } + + /// + /// Which build id an Android symbol upload is keyed by. The id that matters is the one the app reports at runtime, + /// so what Gradle actually produced is read back (the built APK/AAB's asset) instead of being inferred from whether + /// the Gradle block was added to the build script: that block can degrade at configuration time (a user script + /// replacing assets), and a mapping uploaded under another id never deobfuscates. The id file the block also writes + /// under launcher/build/bugsee/ is deliberately not used: Gradle reuses that directory across builds, so it can + /// belong to an earlier build. + /// + public static class AndroidUploadIdentity + { + static readonly Regex Uuid = new Regex( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + public static bool IsUuid(string text) => text != null && Uuid.IsMatch(text.Trim()); + + /// The id in a bugsee_build_id.properties file (bugsee.build_id=<uuid>), or null. + public static string ParseAssetProperties(string text) + { + if (text == null) + { + return null; + } + + foreach (string raw in text.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith("#", StringComparison.Ordinal) || !line.StartsWith("bugsee.build_id", StringComparison.Ordinal)) + { + continue; + } + + int eq = line.IndexOf('='); + if (eq > 0 && line.Substring(0, eq).Trim() == "bugsee.build_id") + { + string value = line.Substring(eq + 1).Trim(); + return IsUuid(value) ? value.ToLowerInvariant() : null; + } + } + + return null; + } + + /// + /// Priority: BUGSEE_BUILD_UUID (an explicit choice), the id the app carries, the id the SDK synthesizes. is the one the app reports, so a different BUGSEE_BUILD_UUID is + /// followed but called out. + /// + public static UploadIdentity Decide(string env, string appAssetId, string synthesized) + { + string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; + if (!string.IsNullOrWhiteSpace(env)) + { + string value = env.Trim(); + string warning = app != null && !string.Equals(app, value, StringComparison.OrdinalIgnoreCase) + ? "BUGSEE_BUILD_UUID (" + value + ") differs from the id the app reports (" + app + "): crashes will not match this upload." + : null; + return new UploadIdentity { Uuid = value, Source = BuildIdSource.Environment, Warning = warning }; + } + + if (app != null) + { + return new UploadIdentity { Uuid = app, Source = BuildIdSource.AppAsset }; + } + + if (synthesized != null) + { + return new UploadIdentity + { + Uuid = synthesized, + Source = BuildIdSource.Synthesized, + Warning = "The built app carries no bugsee_build_id.properties, so the SDK reports the id it derives from the app token, " + + "version name and version code; uploading under that id. If the app is built with a different token, version " + + "name or code than Player Settings (split APKs, versionCodeOverride), set BUGSEE_BUILD_UUID or rebuild.", + }; + } + + return new UploadIdentity { Uuid = null, Source = BuildIdSource.None }; + } + + /// Extra debug-files upload --type proguard arguments: the id (always explicit) and the launcher icon. + public static List MappingArguments(string uuid, string icon) + { + var args = new List(); + if (!string.IsNullOrEmpty(uuid)) + { + args.Add("--uuid"); + args.Add(uuid); + } + + if (!string.IsNullOrEmpty(icon)) + { + args.Add("--icon"); + args.Add(icon); + } + + return args; + } + } +} diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta new file mode 100644 index 0000000..07c008a --- /dev/null +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59cd0311227b44f0843164c6944a5a8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs index 95f0844..1a46b3e 100644 --- a/Runtime/WrapperPolicy/BugseeCliPolicy.cs +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -37,6 +37,8 @@ public sealed class CliSettings /// Release triple of this host; null when none is published. public string Triple; public bool DownloadAllowed; + /// Why a download is not attempted, for the message when nothing is found. + public string DownloadDisabledNote = "Downloading bugsee-cli is disabled (BUGSEE_NO_CLI_DOWNLOAD is set)."; /// Directories a download is cached under, in order of preference. public IList CacheDirectories = new List(); } @@ -47,8 +49,12 @@ public sealed class CliProbe public Func FileExists; /// Runs path --version: the version it reports ("0.8.1"), or null when it does not run. public Func QueryVersion; - /// Downloads and verifies the release into the directory; returns the binary's path. May throw. - public Func Download; + /// Downloads, verifies and unpacks the release once into a staging location; returns the binary's path. May throw. + public Func Fetch; + /// Installs the staged binary under a cache directory (atomically, keeping a copy another build installed); returns the final path. May throw. + public Func Install; + /// Removes what staged. + public Action Discard; } public sealed class CliResolution @@ -84,6 +90,29 @@ public static class BugseeCliPolicy public const string CliName = "bugsee-cli"; + /// + /// SHA-256 of the default release's archives, pinned so the default download does not rest on the download host + /// alone (checked against download.bugsee.com's own .sha256 files when pinned). Null for any other version, + /// which is verified against the published sidecar only. + /// + public static string KnownSha256(string version, string triple) + { + if (version != DefaultVersion) + { + return null; + } + + switch (triple) + { + case "aarch64-apple-darwin": return "0ef257f16ecd5d4666590a355ab20c1d6b44d94f1c17aea4b357a21ed96a7ecf"; + case "x86_64-apple-darwin": return "35f6630cb1f05e9dd46d2cdc05741d72098f0d828fa8790412301c6d56912617"; + case "x86_64-unknown-linux-gnu": return "4d9d985945f3ec20ce56b0dca0bdf208c0d240ea9855d17ea44c6993619b220b"; + case "aarch64-unknown-linux-gnu": return "7617959bbfb14823a7cb86a1fcf876040b24ff1e5e778b3b557a44f7a375f298"; + case "x86_64-pc-windows-msvc": return "08f97390a73ec3b710eac498bb05ce9dc436482af7f6f053f73f7b70e12988a5"; + default: return null; + } + } + /// The release triple (as named in the published archives) of a host, or null when none is published. public static string HostTriple(string os, string arch) { @@ -304,15 +333,21 @@ public static CliResolution Resolve(CliSettings s, CliProbe probe) else { // A bare command name (the old default was "bugsee-cli"): look it up like any other. + var names = s.IsWindows && !explicitPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? new[] { explicitPath, explicitPath + ".exe" } + : new[] { explicitPath }; foreach (var entry in SearchDirectories(s)) { - string candidate = Combine(s, entry.Key, explicitPath); - if (probe.FileExists(candidate)) + foreach (string bare in names) { - resolution.Path = candidate; - resolution.Source = CliSource.Explicit; - resolution.Version = probe.QueryVersion(candidate); - return resolution; + string candidate = Combine(s, entry.Key, bare); + if (probe.FileExists(candidate)) + { + resolution.Path = candidate; + resolution.Source = CliSource.Explicit; + resolution.Version = probe.QueryVersion(candidate); + return resolution; + } } } @@ -368,27 +403,46 @@ public static CliResolution Resolve(CliSettings s, CliProbe probe) if (!s.DownloadAllowed) { - resolution.Notes.Add("Downloading bugsee-cli is disabled (BUGSEE_NO_CLI_DOWNLOAD is set)."); + resolution.Notes.Add(s.DownloadDisabledNote); return resolution; } - foreach (string cacheDir in s.CacheDirectories) + // One download, however many cache directories are tried: a blackholed network must cost one timeout. + string staged; + try + { + staged = probe.Fetch(s.Version); + } + catch (Exception ex) { - try + resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " failed: " + ex.Message); + return resolution; + } + + try + { + foreach (string cacheDir in s.CacheDirectories) { - string downloaded = probe.Download(cacheDir, s.Version); - if (downloaded != null && probe.FileExists(downloaded)) + try { - resolution.Path = downloaded; - resolution.Source = CliSource.Downloaded; - resolution.Version = s.Version; - return resolution; + string installed = probe.Install(staged, cacheDir, s.Version); + if (installed != null && probe.FileExists(installed)) + { + resolution.Path = installed; + resolution.Source = CliSource.Downloaded; + resolution.Version = s.Version; + return resolution; + } + } + catch (Exception ex) + { + resolution.Notes.Add("Installing bugsee-cli " + s.Version + " into " + cacheDir + " failed: " + ex.Message); } } - catch (Exception ex) - { - resolution.Notes.Add("Downloading bugsee-cli " + s.Version + " into " + cacheDir + " failed: " + ex.Message); - } + } + finally + { + probe.Discard?.Invoke(staged); } return resolution; diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs index 43245c3..ce78d39 100644 --- a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -57,7 +57,7 @@ public static string Snippet() => string.Join("\n", new[] " @TaskAction", " void run() {", " def dst = outputDir.get().asFile", - " project.delete(dst)", + " dst.deleteDir()", " dst.mkdirs()", " def id = idFile.get().asFile.text.trim()", " if (!id.isEmpty()) {", @@ -146,6 +146,31 @@ public static string Apply(string launcherGradle, out BuildIdBlockStatus status) return MarkedGradleBlock.JoinLines(lines, newline); } + /// + /// Takes the block out again (the generated project is not always rebuilt from the template, so a block added + /// by an earlier build stays unless it is removed). Returns the text unchanged when there is none. + /// + public static string Remove(string launcherGradle) + { + if (launcherGradle == null) + { + throw new ArgumentNullException(nameof(launcherGradle)); + } + + var lines = MarkedGradleBlock.SplitLines(launcherGradle, out string newline); + int begin = lines.FindIndex(l => l.Trim() == Begin); + int end = begin < 0 ? -1 : lines.FindIndex(begin, l => l.Trim() == End); + if (begin < 0 || end < begin) + { + return launcherGradle; + } + + // The blank line Apply put after the block goes with it. + int count = end - begin + 1 + (end + 1 < lines.Count && lines[end + 1].Length == 0 ? 1 : 0); + lines.RemoveRange(begin, count); + return MarkedGradleBlock.JoinLines(lines, newline); + } + static bool SameLines(List a, List b) { if (a.Count != b.Count) diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 44c73c1..93f141a 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -92,14 +92,30 @@ public static bool AgpMeetsBugseePlugin(string rootBuildFile) } /// - /// needs the AGP 7.4 artifacts API (checked on 7.4.2) and is for versions the - /// plugin cannot run on (below 8.6): 7.4 up to 8.5. False for older AGPs (Unity 2021.3's first patch releases - /// ship AGP 4.0.1, which has no androidComponents) and when the version is unknown. + /// is for AGP 7.4.x, the only line it was run on (7.4.2, Unity 2021.3 and 2022.3). + /// Other AGPs below the plugin's 8.6 minimum are left to the SDK's own build id rather than trusted with an + /// unverified build script. False for older AGPs (Unity 2021.3's first patch releases ship AGP 4.0.1, which has + /// no androidComponents) and when the version is unknown. /// public static bool AgpSupportsBuildIdBlock(string rootBuildFile) { - return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && - ((major == 7 && minor >= 4) || (major == 8 && minor < 6)); + return TryGetAgpVersion(rootBuildFile, out int major, out int minor) && major == 7 && minor == 4; + } + + static readonly Regex GradleVersionCode = new Regex(@"^\s*versionCode\s*=?\s*(?\d+)\s*$", RegexOptions.Multiline); + static readonly Regex GradleVersionName = new Regex(@"^\s*versionName\s*=?\s*['""](?[^'""\r\n]*)['""]\s*$", RegexOptions.Multiline); + + /// + /// The versionCode / versionName of the generated launcher build.gradle's defaultConfig: what the + /// APK carries, which can differ from Player Settings (split APK version codes, an override). Each is null when + /// the file does not state it as a plain literal. + /// + public static void ReadGradleVersion(string launcherGradle, out string versionName, out string versionCode) + { + Match code = launcherGradle == null ? Match.Empty : GradleVersionCode.Match(launcherGradle); + Match name = launcherGradle == null ? Match.Empty : GradleVersionName.Match(launcherGradle); + versionCode = code.Success ? code.Groups["v"].Value : null; + versionName = name.Success ? name.Groups["v"].Value : null; } const string PluginMarker = "bugsee:gradle-plugin"; diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs new file mode 100644 index 0000000..eb7504d --- /dev/null +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -0,0 +1,97 @@ +using System.Linq; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class AndroidUploadIdentityTests + { + const string App = "26958224-3a30-3621-9ce7-564c211abee3"; + const string Synth = "0f5c0792-affb-3962-af95-d6e66c5828e2"; + + [Test] + public void The_id_the_app_carries_wins_over_the_synthesized_id() + { + var id = AndroidUploadIdentity.Decide(null, App, Synth); + + Assert.That(id.Uuid, Is.EqualTo(App)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); + Assert.That(id.Warning, Is.Null); + } + + [Test] + public void With_no_asset_the_sdks_own_id_is_used_and_the_warning_says_why() + { + // The block degraded (older AGP, a script replacing assets): the app reports the synthesized id, and the + // mapping must not be uploaded under a content id the app never reports. + var id = AndroidUploadIdentity.Decide(null, null, Synth); + + Assert.That(id.Uuid, Is.EqualTo(Synth)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.Synthesized)); + Assert.That(id.Warning, Does.Contain("no bugsee_build_id.properties").And.Contain("BUGSEE_BUILD_UUID")); + } + + [Test] + public void An_environment_override_wins_and_a_conflict_with_the_app_is_called_out() + { + var agrees = AndroidUploadIdentity.Decide(App.ToUpperInvariant(), App, Synth); + var conflicts = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", App, Synth); + + Assert.That(agrees.Source, Is.EqualTo(BuildIdSource.Environment)); + Assert.That(agrees.Warning, Is.Null); + Assert.That(conflicts.Uuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(conflicts.Warning, Does.Contain(App).And.Contain("will not match")); + } + + [Test] + public void Malformed_ids_are_ignored_and_nothing_known_yields_none() + { + Assert.That(AndroidUploadIdentity.Decide(null, "not-a-uuid", Synth).Source, Is.EqualTo(BuildIdSource.Synthesized)); + var none = AndroidUploadIdentity.Decide(null, null, null); + Assert.That(none.Source, Is.EqualTo(BuildIdSource.None)); + Assert.That(none.Uuid, Is.Null); + } + + [Test] + public void The_asset_file_is_parsed_like_the_sdk_reads_it() + { + string text = "# Bugsee build identifier - auto-generated, do not edit.\nbugsee.build_id=" + App.ToUpperInvariant() + "\r\n"; + + Assert.That(AndroidUploadIdentity.ParseAssetProperties(text), Is.EqualTo(App)); + Assert.That(AndroidUploadIdentity.ParseAssetProperties("bugsee.build_id=null\n"), Is.Null); + Assert.That(AndroidUploadIdentity.ParseAssetProperties("# bugsee.build_id=" + App), Is.Null); + Assert.That(AndroidUploadIdentity.ParseAssetProperties("bugsee.build_id_other=" + App), Is.Null); + Assert.That(AndroidUploadIdentity.ParseAssetProperties(null), Is.Null); + } + + [Test] + public void The_mapping_arguments_always_carry_the_id_and_the_icon_when_there_is_one() + { + Assert.That(AndroidUploadIdentity.MappingArguments(App, "/p/app_icon.png"), + Is.EqualTo(new[] { "--uuid", App, "--icon", "/p/app_icon.png" })); + Assert.That(AndroidUploadIdentity.MappingArguments(App, null), Is.EqualTo(new[] { "--uuid", App })); + Assert.That(AndroidUploadIdentity.MappingArguments(null, null), Is.Empty); + } + + [Test] + public void The_apk_version_is_read_from_the_generated_launcher() + { + string gradle = "android {\n defaultConfig {\n versionCode 2\n versionName '0.1.0'\n }\n}\n"; + + GradleTemplateDefaults.ReadGradleVersion(gradle, out var name, out var code); + + Assert.That(name, Is.EqualTo("0.1.0")); + Assert.That(code, Is.EqualTo("2")); + } + + [Test] + public void A_version_the_launcher_does_not_state_as_a_literal_stays_unknown() + { + GradleTemplateDefaults.ReadGradleVersion("versionCode project.ext.code\nversionName rootProject.name\n", out var name, out var code); + + Assert.That(name, Is.Null); + Assert.That(code, Is.Null); + GradleTemplateDefaults.ReadGradleVersion(null, out name, out code); + Assert.That(code, Is.Null); + } + } +} diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs index 489bbe7..965b78a 100644 --- a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -12,17 +12,26 @@ sealed class Host { public readonly Dictionary Files = new Dictionary(); // path -> version ("" = does not run) public readonly List Downloads = new List(); - public Func OnDownload; + public readonly List Installs = new List(); + public readonly List Discards = new List(); + public Func OnFetch; + public Func OnInstall; public CliProbe Probe() => new CliProbe { FileExists = Files.ContainsKey, QueryVersion = p => Files.TryGetValue(p, out var v) && v != "" ? v : null, - Download = (dir, version) => + Fetch = version => { - Downloads.Add(dir + "|" + version); - return OnDownload?.Invoke(dir, version); + Downloads.Add(version); + return OnFetch?.Invoke(version); }, + Install = (staged, dir, version) => + { + Installs.Add(dir); + return OnInstall?.Invoke(staged, dir, version); + }, + Discard = staged => Discards.Add(staged), }; } @@ -172,6 +181,31 @@ public void Explicit_path_that_does_not_exist_is_reported_and_discovery_continue Assert.That(r.Notes.Single(), Does.Contain("/nope/bugsee-cli").And.Contain("does not exist")); } + [Test] + public void On_windows_a_bare_command_also_tries_the_exe_name() + { + var host = new Host(); + host.Files["C:\\tools\\bugsee-cli.exe"] = "0.8.1"; + var s = new CliSettings { IsWindows = true, PathEnv = "C:\\tools", ExplicitPath = "bugsee-cli" }; + + var r = BugseeCliPolicy.Resolve(s, host.Probe()); + + Assert.That(r.Path, Is.EqualTo("C:\\tools\\bugsee-cli.exe")); + } + + [Test] + public void The_default_release_has_a_pinned_hash_for_every_published_host_and_other_versions_none() + { + foreach (var triple in new[] { "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc" }) + { + Assert.That(BugseeCliPolicy.KnownSha256(BugseeCliPolicy.DefaultVersion, triple), Has.Length.EqualTo(64), triple); + Assert.That(BugseeCliPolicy.ParseSha256Sidecar(BugseeCliPolicy.KnownSha256(BugseeCliPolicy.DefaultVersion, triple)), Is.Not.Null); + } + + Assert.That(BugseeCliPolicy.KnownSha256("0.8.2", "aarch64-apple-darwin"), Is.Null); + Assert.That(BugseeCliPolicy.KnownSha256(BugseeCliPolicy.DefaultVersion, "riscv"), Is.Null); + } + [Test] public void Explicit_bare_command_is_looked_up_in_every_search_directory() { @@ -215,10 +249,11 @@ public void A_cached_download_is_used_without_downloading_again() } [Test] - public void A_missing_cli_is_downloaded_into_the_first_cache_directory() + public void A_missing_cli_is_downloaded_once_and_installed_into_the_first_cache_directory() { var host = new Host(); - host.OnDownload = (dir, version) => + host.OnFetch = version => "/tmp/stage/bugsee-cli"; + host.OnInstall = (staged, dir, version) => { string path = dir + "/" + version + "/aarch64-apple-darwin/bugsee-cli"; host.Files[path] = version; @@ -229,25 +264,53 @@ public void A_missing_cli_is_downloaded_into_the_first_cache_directory() Assert.That(r.Source, Is.EqualTo(CliSource.Downloaded)); Assert.That(r.Path, Is.EqualTo("/Users/dev/Library/Caches/Bugsee/cli/0.8.1/aarch64-apple-darwin/bugsee-cli")); - Assert.That(host.Downloads, Is.EqualTo(new[] { "/Users/dev/Library/Caches/Bugsee/cli|0.8.1" })); + Assert.That(host.Downloads, Is.EqualTo(new[] { "0.8.1" })); + Assert.That(host.Installs, Is.EqualTo(new[] { "/Users/dev/Library/Caches/Bugsee/cli" })); + Assert.That(host.Discards, Is.EqualTo(new[] { "/tmp/stage/bugsee-cli" }), "the staged copy is always removed"); } [Test] - public void A_failing_download_falls_back_to_the_next_cache_directory_then_reports_every_failure() + public void A_failing_download_costs_one_attempt_whatever_the_number_of_cache_directories() { var host = new Host(); - host.OnDownload = (dir, version) => throw new InvalidOperationException("SHA-256 mismatch in " + dir); + host.OnFetch = version => throw new InvalidOperationException("timed out"); var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); Assert.That(r.Found, Is.False); - Assert.That(host.Downloads, Has.Count.EqualTo(2)); - Assert.That(r.Notes, Has.Count.EqualTo(2)); + Assert.That(host.Downloads, Has.Count.EqualTo(1)); + Assert.That(host.Installs, Is.Empty); string message = BugseeCliPolicy.NotFoundMessage(r); - Assert.That(message, Does.Contain("SHA-256 mismatch").And.Contain("BUGSEE_CLI_PATH").And.Contain("install.sh")); + Assert.That(message, Does.Contain("timed out").And.Contain("BUGSEE_CLI_PATH").And.Contain("install.sh")); Assert.That(message, Does.Contain("build continues")); } + [Test] + public void An_unwritable_first_cache_directory_falls_back_to_the_next_without_downloading_again() + { + var host = new Host(); + host.OnFetch = version => "/tmp/stage/bugsee-cli"; + host.OnInstall = (staged, dir, version) => + { + if (dir.StartsWith("/Users/dev/Library")) + { + throw new InvalidOperationException("read-only file system"); + } + + string path = dir + "/bugsee-cli"; + host.Files[path] = version; + return path; + }; + + var r = BugseeCliPolicy.Resolve(Mac(), host.Probe()); + + Assert.That(r.Path, Is.EqualTo("/proj/Library/Bugsee/cli/bugsee-cli")); + Assert.That(host.Downloads, Has.Count.EqualTo(1)); + Assert.That(host.Installs, Has.Count.EqualTo(2)); + Assert.That(r.Notes.Single(), Does.Contain("read-only file system")); + Assert.That(host.Discards, Has.Count.EqualTo(1)); + } + [Test] public void Download_is_not_attempted_when_it_is_not_allowed() { @@ -258,6 +321,10 @@ public void Download_is_not_attempted_when_it_is_not_allowed() Assert.That(r.Found, Is.False); Assert.That(host.Downloads, Is.Empty); Assert.That(r.Notes.Single(), Does.Contain("BUGSEE_NO_CLI_DOWNLOAD")); + + var s = Mac(download: false); + s.DownloadDisabledNote = "failed earlier in this Editor session"; + Assert.That(BugseeCliPolicy.Resolve(s, host.Probe()).Notes.Single(), Is.EqualTo("failed earlier in this Editor session")); } [Test] diff --git a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs index f527ed8..bf85e19 100644 --- a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -53,6 +53,17 @@ public void A_block_from_an_earlier_package_version_is_replaced_in_place() Assert.That(fixedUp, Is.EqualTo(once)); } + [TestCase("2021.3.45f1")] + [TestCase("2022.3.62f3")] + public void Removing_the_block_restores_the_template_exactly(string unity) + { + var launcher = Template(unity, "launcherTemplate.gradle"); + var patched = GradleBuildIdBlock.Apply(launcher, out _); + + Assert.That(GradleBuildIdBlock.Remove(patched), Is.EqualTo(launcher)); + Assert.That(GradleBuildIdBlock.Remove(launcher), Is.EqualTo(launcher), "nothing to remove"); + } + [Test] public void A_launcher_without_a_top_level_android_block_is_left_alone() { @@ -75,7 +86,9 @@ public void Crlf_launchers_keep_their_line_endings() } [Test] - public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4() + // Text checks only: no Gradle runs in these tests. That the block loads and produces the asset is what the + // real 2021.3 / 2022.3 builds in Documentation~/index.md verify. + public void The_block_text_keeps_the_properties_checked_by_the_real_builds() { var snippet = GradleBuildIdBlock.Snippet(); @@ -83,6 +96,7 @@ public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4 Assert.That(snippet, Does.Contain("catch (Throwable t)")); Assert.That(snippet, Does.Contain("variant.artifacts.get(com.android.build.api.artifact.SingleArtifact.OBFUSCATION_MAPPING_FILE.INSTANCE)")); Assert.That(snippet, Does.Not.Contain("toListenTo"), "AGP 7.4.2 has no toListenTo"); + Assert.That(snippet, Does.Not.Contain("project.delete"), "Task.project at execution time breaks the configuration cache"); Assert.That(snippet, Does.Not.Contain("toTransform"), "transforming ASSETS on AGP 7.4.2 drops every library asset, Unity's game data included"); Assert.That(snippet, Does.Contain("variant.sources.assets.addGeneratedSourceDirectory(asset")); Assert.That(snippet, Does.Contain("bugsee_build_id.properties")); @@ -91,13 +105,16 @@ public void The_block_never_fails_the_build_and_uses_only_api_present_in_agp_7_4 } [TestCase("id 'com.android.application' version '7.4.2' apply false", true)] + [TestCase("id 'com.android.application' version '7.4.0' apply false", true)] + [TestCase("id 'com.android.application' version '7.3.1' apply false", false)] [TestCase("id 'com.android.application' version '7.1.2' apply false", false)] - [TestCase("id 'com.android.application' version '8.3.0' apply false", true)] + [TestCase("id 'com.android.application' version '7.5.0' apply false", false)] + [TestCase("id 'com.android.application' version '8.3.0' apply false", false)] [TestCase("id 'com.android.application' version '8.6.0' apply false", false)] [TestCase("id 'com.android.application' version '9.0.0' apply false", false)] [TestCase("classpath 'com.android.tools.build:gradle:4.0.1'", false)] [TestCase("no agp here", false)] - public void The_block_is_for_agp_7_4_up_to_the_plugins_own_minimum(string root, bool expected) + public void The_block_is_only_for_agp_7_4_the_one_line_it_was_run_on(string root, bool expected) { Assert.That(GradleTemplateDefaults.AgpSupportsBuildIdBlock(root), Is.EqualTo(expected)); } diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index c8dd506..780fef1 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -5,9 +5,9 @@ namespace Bugsee.WrapperPolicy.Tests { /// - /// The Editor scripts are compiled only by Unity, so the wiring the policy classes depend on is checked in source: - /// the upload resolves its CLI through the locator, and a mapping is keyed by the SDK id when no build id asset - /// runs (Unity 2021.3 / 2022.3). + /// The Editor scripts are compiled only by Unity, so these check, in source text and nothing more, that the Editor + /// code calls the policy classes whose behaviour is tested elsewhere (BugseeCliPolicy, AndroidUploadIdentity, + /// AndroidBuildUuid). They prove wiring, not behaviour: real builds do that (see Documentation~/index.md). /// public class OldUnitySymbolUploadSourceTests { @@ -42,73 +42,50 @@ static string Body(string source, string signature) } [Test] - public void The_runner_starts_the_resolved_binary_and_never_the_bare_name() + public void The_runner_starts_the_resolved_binary_with_an_argument_list() { var source = Source("Editor/BugseeCliRunner.cs"); Assert.That(Body(source, "public static string CliPath"), Does.Contain("BugseeCliLocator.Resolve()")); Assert.That(Body(source, "public static int Run("), Does.Contain("BugseeCliLocator.ReportIfMissing()")); - Assert.That(source, Does.Not.Contain("?? \"bugsee-cli\"")); + Assert.That(Body(source, "public static int Run("), Does.Contain("psi.ArgumentList.Add(")); + Assert.That(source, Does.Not.Contain("Arguments = string.Join")); } [Test] - public void The_locator_downloads_only_over_https_and_checks_the_published_checksum() - { - var body = Body(Source("Editor/BugseeCliLocator.cs"), "static string Download("); - - Assert.That(body, Does.Contain("StartsWith(\"https://\"")); - Assert.That(body, Does.Contain("BugseeCliPolicy.ParseSha256Sidecar(")); - Assert.That(body, Does.Contain("Sha256Hex(archive)")); - Assert.That(body, Does.Contain("SHA-256 mismatch")); - } - - [Test] - public void The_locator_forgets_a_failed_lookup_when_the_next_build_starts() + public void The_download_is_verified_and_installed_through_the_policy_steps() { var source = Source("Editor/BugseeCliLocator.cs"); + var fetch = Body(source, "static string FetchUnchecked("); - Assert.That(source, Does.Contain("class BugseeCliResolutionReset : IPreprocessBuildWithReport")); + Assert.That(fetch, Does.Contain("StartsWith(\"https://\"")); + Assert.That(fetch, Does.Contain("BugseeCliPolicy.KnownSha256(")); + Assert.That(fetch, Does.Contain("SHA-256 mismatch")); + Assert.That(Body(source, "static string Install("), Does.Contain("File.Move(temp, finalBinary)")); + Assert.That(source, Does.Contain("Fetch = version => Fetch(settings, version)")); Assert.That(source, Does.Contain("public void OnPreprocessBuild(BuildReport report) => BugseeCliLocator.Reset();")); } [Test] - public void The_mapping_upload_carries_the_sdk_build_id_unless_the_gradle_plugin_runs() + public void The_symbol_upload_keys_mapping_and_native_uploads_by_the_id_read_back_from_the_build() { var body = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadAndroidSymbols("); - Assert.That(body, Does.Contain("if (!BugseeAndroidGradleSetup.BuildIdAssetInjected)")); - Assert.That(body, Does.Contain("mappingExtra.Add(\"--uuid\")")); - Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveBuildUuid(report)")); - Assert.That(body, Does.Contain("mappingExtra.Add(\"--icon\")"), "the plugin attaches the launcher icon to the mapping"); - Assert.That(Body(Source("Editor/BugseeSymbolUpload.cs"), "static string FindLauncherIcon("), Does.Contain("AndroidIconPicker.Pick(")); + Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); + Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); + Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid")); } [Test] - public void An_empty_mapping_is_never_uploaded() + public void The_identity_is_read_from_the_apk_asset_before_the_synthesized_id() { - var source = Source("Editor/BugseeSymbolUpload.cs"); - - Assert.That(Body(source, "static void UploadIfFound("), Does.Contain("if (IsEmptyFile(file))")); - Assert.That(Body(source, "static bool IsEmptyFile("), Does.Contain("new FileInfo(path).Length == 0")); - } - - [Test] - public void The_android_build_id_is_the_one_the_sdk_derives_from_the_app_as_built() - { - var body = Body(Source("Editor/BugseeBuildIdentity.cs"), "public static string ResolveBuildUuid("); + var source = Source("Editor/BugseeBuildIdentity.cs"); + var body = Body(source, "public static UploadIdentity ResolveAndroidUploadIdentity("); + Assert.That(body, Does.Contain("ReadAppAssetId(report?.summary.outputPath)")); + Assert.That(source, Does.Not.Contain("build-id.txt"), "Gradle's id file can belong to an earlier build"); Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); - Assert.That(body, Does.Contain("names.BundleVersion")); - Assert.That(body, Does.Contain("names.VersionCode")); - } - - [Test] - public void The_gradle_setup_records_whether_the_build_id_asset_was_added_for_this_build_only() - { - var source = Source("Editor/BugseeAndroidGradleSetup.cs"); - - Assert.That(Body(source, "public void OnPreprocessBuild("), Does.Contain("BuildIdAssetInjected = false")); - Assert.That(Body(source, "static void PatchGeneratedProject("), Does.Contain("BuildIdAssetInjected = pluginPresent || blockPresent")); + Assert.That(body, Does.Contain("GradleTemplateDefaults.ReadGradleVersion(")); } } } From ad4fc659090f9da39cb34bb0391ad7c5781c1417 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:14:05 +0500 Subject: [PATCH 153/157] Fix the second review round: explicit --uuid on directory ELF uploads, split-APK id read-back, block name clash and cleanup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - elf uploads of a directory carried no --uuid, which bugsee-cli 0.8.1 rejects: one argument builder for both. - Split APKs by target architecture: Unity's output is a folder of per-ABI APKs; read the id from each, warn on disagreement, and find the symbols zip named after the player without .apk. - The Gradle block clashed with com.android.build.OutputFile that the split-APK launcher imports (found by a real build): annotations are fully qualified. - Remove the block whenever it is not applied (AGP switch); version code is not trusted under versionCodeOverride; BUGSEE_BUILD_UUID must be a UUID; docs say AGP 8.0-8.5 and qualify the Unity 6 column. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 8 +- Editor/BugseeAndroidGradleSetup.cs | 18 ++-- Editor/BugseeBuildIdentity.cs | 70 +++++++++++++-- Editor/BugseeSymbolUpload.cs | 27 ++---- .../WrapperPolicy/AndroidUploadIdentity.cs | 89 ++++++++++++++++++- Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 12 +-- .../WrapperPolicy/GradleTemplateDefaults.cs | 8 +- .../AndroidUploadIdentityTests.cs | 72 +++++++++++++++ .../WrapperPolicy/GradleBuildIdBlockTests.cs | 13 +++ .../OldUnitySymbolUploadSourceTests.cs | 6 +- 11 files changed, 278 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddaa9be..491d73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (else the file the block writes under `launcher/build/bugsee/`) and uploads the mapping and the native symbols under it, with `--uuid` always explicit. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. diff --git a/Documentation~/index.md b/Documentation~/index.md index d4ca70e..f9aaf38 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -158,7 +158,7 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle 8.7+ and fails to load there (checked on 2022.3.62f3: `NoClassDefFoundError: KotlinCompilerPluginSupportPlugin`, then `NoSuchMethodError` on `OutOperationRequest.toListenTo`). What it does for a Unity app, and what stands in for it: -| Gradle plugin function | Unity 6 | Unity 2021.3 / 2022.3 | +| Gradle plugin function | Unity 6 with AGP 8.6+ (6000.0.84f1 has AGP 9.0.0; an earlier 6000.0 on AGP below 8.6 gets neither the plugin nor the block) | Unity 2021.3 / 2022.3 (AGP 7.4.x) | |---|---|---| | Declare `bugsee-android`, `bugsee-android-ndk` | Package (Maven injection) | Same | | `assets/bugsee_build_id.properties` = UUID of the R8 mapping (the id the SDK reports) | Plugin | The package adds the same asset task to the launcher (`GradleBuildIdBlock`), for AGP 7.4.x only (the one line it was run on; other AGPs below 8.6 get no block). With R8 off, or no block, no asset is written and the SDK derives its own id | @@ -171,6 +171,8 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; split-APK version-code schemes and `versionNameSuffix` set outside Unity can still differ: set `BUGSEE_BUILD_UUID` then). `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding (and removes an earlier) asset block, to use the derived id only. With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. +With Split APKs by target architecture the id is read from every APK; if they disagree (or only some carry one) the build warns and the upload uses the synthesized id. A launcher that overrides the version code per output (`versionCodeOverride`, what Unity's split version-code setting adds) is not trusted for the synthesized id's version code: Player Settings' is used. + Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, uploads to UNITYTA; the generated launcher's Gradle ran AGP 7.4.2 / Gradle 7.5.1 on both): | Unity | Build | R8 | Result | @@ -180,10 +182,12 @@ Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, u | 2022.3.62f3 | development | on | builds; APK asset id equals the uploaded mapping id | | 2022.3.62f3 | release | on | same, plus emulator native, Java and managed crashes symbolicated | | 2022.3.62f3 | release | on, `BUGSEE_NO_GRADLE_BUILD_ID=1` | builds; no asset; mapping uploaded under the synthesized id with the warning; the Java crash on an emulator grouped with the symbolicated issue | +| 2022.3.62f3 | release, Split APKs by target architecture (ARMv7 + ARM64) | on | builds; Unity writes a folder `split.apk/` of per-ABI APKs: both carry the same asset id, equal to the uploaded mapping id; the symbols zip (named after `split`, not `split.apk`) is found and uploaded | +| 2022.3.62f3 | Export Project | on | builds; no native libraries exist in the export, so no ELF upload (the CLI refuses a directory upload without `--uuid`, which every ELF upload now carries; checked with `bugsee-cli --dry-run`) | | 2021.3.45f1 | release | off, development off | builds, as above | | 2021.3.45f1 | release, development | on | builds; APK asset id equals the uploaded mapping id | -Not verified: AGP 7.5 to 8.5 (no block is added there by design), Unity 2021.3 releases with AGP below 7.4, AAB output, build with Gradle configuration cache (the block avoids `Task.project` at execution time but was run without it), and Windows/Linux Editors. +Not verified: AGP 8.0 to 8.5 (no block is added there by design), Unity 2021.3 releases with AGP below 7.4, AAB output, build with Gradle configuration cache (the block avoids `Task.project` at execution time but was run without it), and Windows/Linux Editors. Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. If you do minify, add keep rules for the Unity wrapper classes: the package's `bugsee-unity-wrapper.proguard` is not applied by Unity unless you enable Player Settings > Publishing Settings > Custom Proguard File and copy the rules into `Assets/Plugins/Android/proguard-user.txt`, and that file does not yet cover `com.bugsee.unity.UnityManagedException` or `UnityWrapperIdentity` (R8 removes them: `ClassNotFoundException` at launch or on the first managed exception). diff --git a/Editor/BugseeAndroidGradleSetup.cs b/Editor/BugseeAndroidGradleSetup.cs index f2e1898..9c17c1a 100644 --- a/Editor/BugseeAndroidGradleSetup.cs +++ b/Editor/BugseeAndroidGradleSetup.cs @@ -110,27 +110,29 @@ static void PatchGeneratedProject(string projectRoot) LauncherMarkedGradlePatch()); } - if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") != null) - { - // Opt-out (the Unity version regenerates only what changed, so a block from an earlier build is removed). - patchedLauncher = GradleBuildIdBlock.Remove(patchedLauncher); - } - else if (!apply && GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) + if (!apply && Environment.GetEnvironmentVariable("BUGSEE_NO_GRADLE_BUILD_ID") == null && + GradleTemplateDefaults.AgpSupportsBuildIdBlock(root)) { patchedLauncher = GradleBuildIdBlock.Apply(patchedLauncher, out BuildIdBlockStatus blockStatus); - if (blockStatus != BuildIdBlockStatus.NoAnchor) + if (blockStatus == BuildIdBlockStatus.Applied) { Debug.Log( "[Bugsee] Added the build id asset task (assets/bugsee_build_id.properties, the id of the R8 mapping) to " + launcherPath + ": the part of the Bugsee Gradle plugin that works on this AGP. The upload uses whatever id the built app carries."); } - else + else if (blockStatus == BuildIdBlockStatus.NoAnchor) { Debug.LogWarning( "[Bugsee] No top-level android { } block in " + launcherPath + ", so the build id asset was not added. " + "The SDK derives its own build id and the mapping is uploaded under it."); } } + else + { + // Not applying it (plugin, another AGP, opt-out): the Unity version regenerates only what changed, so a + // block an earlier build added (AGP 7.4, then a switch to 8.x) must not stay behind. + patchedLauncher = GradleBuildIdBlock.Remove(patchedLauncher); + } if (!string.Equals(root, patchedRoot, StringComparison.Ordinal)) { diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 1cb4605..0ef8a61 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using System; +using System.Collections.Generic; using System.IO; using Bugsee.WrapperPolicy; using UnityEditor; @@ -88,23 +89,76 @@ public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) code = gradleCode ?? code; } + var appId = AndroidUploadIdentity.SelectAppAssetId(ReadAppAssetIds(report?.summary.outputPath), out var conflict); + if (conflict != null) + { + Debug.LogWarning("Bugsee: " + conflict); + } + return AndroidUploadIdentity.Decide( Environment.GetEnvironmentVariable("BUGSEE_BUILD_UUID"), - ReadAppAssetId(report?.summary.outputPath), - AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code)); + appId, + AndroidBuildUuid.Synthesize(BugseeCliRunner.AppToken, name, code), + conflict); } - /// The id in the APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). - static string ReadAppAssetId(string outputPath) + /// + /// The build id asset of each APK/AAB this build produced: the output file itself, or, when Unity wrote one APK + /// per ABI (Split APKs by target architecture), where that path is a folder holding them, every APK in it. + /// An entry is null when that file carries no id or cannot be read. + /// + static List ReadAppAssetIds(string outputPath) { + var ids = new List(); + if (string.IsNullOrEmpty(outputPath)) + { + return ids; + } + try { - if (string.IsNullOrEmpty(outputPath) || !File.Exists(outputPath)) + if (File.Exists(outputPath)) + { + ids.Add(ReadAppAssetId(outputPath)); + return ids; + } + + // Split APKs by target architecture: Unity turns the output path into a folder holding one APK per ABI. + if (Directory.Exists(outputPath)) + { + foreach (var apk in Directory.GetFiles(outputPath, "*.apk")) + { + ids.Add(ReadAppAssetId(apk)); + } + + return ids; + } + + var dir = Path.GetDirectoryName(Path.GetFullPath(outputPath)); + var stem = Path.GetFileNameWithoutExtension(outputPath); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir) && !string.IsNullOrEmpty(stem) && + outputPath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) { - return null; + foreach (var apk in Directory.GetFiles(dir, stem + "*.apk")) + { + ids.Add(ReadAppAssetId(apk)); + } } + } + catch (Exception ex) + { + Debug.LogWarning($"Bugsee: could not look for split APKs of {outputPath}: {ex.Message}"); + } + + return ids; + } - using (var zip = System.IO.Compression.ZipFile.OpenRead(outputPath)) + /// The id in one APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). + static string ReadAppAssetId(string path) + { + try + { + using (var zip = System.IO.Compression.ZipFile.OpenRead(path)) { foreach (var entry in zip.Entries) { @@ -121,7 +175,7 @@ static string ReadAppAssetId(string outputPath) } catch (Exception ex) { - Debug.LogWarning($"Bugsee: could not read the build id asset from {outputPath}: {ex.Message}"); + Debug.LogWarning($"Bugsee: could not read the build id asset from {path}: {ex.Message}"); } return null; diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 9c2fae3..4bb37b4 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -74,7 +74,7 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil // matches it). Only this build's own file is uploaded, see BuildArtifactChoice. var stem = OutputStem(report); var allowGeneric = OutputIsDirectory(report); - // Keyed by the id the built app reports (read back from the APK or Gradle's output, see + // Keyed by the id the built app reports (read back from the built APK/AAB, see // BugseeBuildIdentity.ResolveAndroidUploadIdentity), never assumed from what the build script contained. var identity = BugseeBuildIdentity.ResolveAndroidUploadIdentity(report); // Said only when a mapping is actually uploaded under it (R8 off: nothing to match). @@ -153,15 +153,7 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string // --uuid is the BUILD_UUID correlation key (required by CLI); each .so is still // keyed by its own GNU build-id. Debug.Log($"Bugsee: uploading Android symbols zip via {BugseeCliRunner.CliPath}: {bestZip}"); - var args = new List - { - "debug-files", "upload", bestZip, - "--type", "elf", - "--version", version, - "--build", build, - }; - args.Add("--uuid"); - args.Add(buildUuid); + var args = AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid); var exit = BugseeCliRunner.Run(args); if (exit != 0) { @@ -175,13 +167,7 @@ static void UploadSymbolsZipOrElf(BuildReport report, List roots, string foreach (var dir in elfDirs) { Debug.Log($"Bugsee: uploading ELF folder via {BugseeCliRunner.CliPath}: {dir}"); - var exit = BugseeCliRunner.Run(new[] - { - "debug-files", "upload", dir, - "--type", "elf", - "--version", version, - "--build", build, - }); + var exit = BugseeCliRunner.Run(AndroidUploadIdentity.ElfArguments(dir, version, build, buildUuid)); if (exit != 0) { Debug.LogWarning( @@ -345,7 +331,12 @@ internal static string OutputStem(BuildReport report) if (string.IsNullOrEmpty(path)) return null; if (OutputIsDirectory(report)) { - return Path.GetFileName(path.TrimEnd('/', '\\')); + // Split APKs by target architecture turn "Game.apk" into a folder of that name; Unity still names the + // symbols zip after "Game". + var folder = Path.GetFileName(path.TrimEnd('/', '\\')); + return folder.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || folder.EndsWith(".aab", StringComparison.OrdinalIgnoreCase) + ? Path.GetFileNameWithoutExtension(folder) + : folder; } return Path.GetFileNameWithoutExtension(path); diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs index 656928b..7d7eb6b 100644 --- a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -70,7 +70,36 @@ public static string ParseAssetProperties(string text) /// Priority: BUGSEE_BUILD_UUID (an explicit choice), the id the app carries, the id the SDK synthesizes. is the one the app reports, so a different BUGSEE_BUILD_UUID is /// followed but called out. /// - public static UploadIdentity Decide(string env, string appAssetId, string synthesized) + public static UploadIdentity Decide(string env, string appAssetId, string synthesized, string appAssetConflict = null) + { + var result = DecideCore(env, appAssetId, synthesized); + if (appAssetConflict != null) + { + result.Warning = result.Warning == null ? appAssetConflict : appAssetConflict + " " + result.Warning; + } + + return result; + } + + static UploadIdentity DecideCore(string env, string appAssetId, string synthesized) + { + string invalidEnv = null; + if (!string.IsNullOrWhiteSpace(env) && !IsUuid(env)) + { + invalidEnv = "BUGSEE_BUILD_UUID is not a UUID and is ignored."; + env = null; + } + + var result = DecideValid(env, appAssetId, synthesized); + if (invalidEnv != null) + { + result.Warning = result.Warning == null ? invalidEnv : invalidEnv + " " + result.Warning; + } + + return result; + } + + static UploadIdentity DecideValid(string env, string appAssetId, string synthesized) { string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; if (!string.IsNullOrWhiteSpace(env)) @@ -102,6 +131,64 @@ public static UploadIdentity Decide(string env, string appAssetId, string synthe return new UploadIdentity { Uuid = null, Source = BuildIdSource.None }; } + /// + /// The id of an app delivered as several APKs (Split APKs by target architecture): the one id they all carry. Null + /// when none carries one; says so when they differ or only some carry it, because + /// one upload cannot match both. + /// + public static string SelectAppAssetId(IList idsPerApk, out string conflict) + { + conflict = null; + string first = null; + bool anyMissing = false; + bool differ = false; + foreach (string raw in idsPerApk) + { + string id = IsUuid(raw) ? raw.Trim().ToLowerInvariant() : null; + if (id == null) + { + anyMissing = true; + } + else if (first == null) + { + first = id; + } + else if (first != id) + { + differ = true; + } + } + + if (first == null) + { + return null; + } + + if (differ || anyMissing) + { + conflict = "The APKs of this build do not all carry the same build id (" + + (differ ? "different ids" : "some have none") + "): crashes from some of them will not match the uploaded mapping."; + return differ ? null : first; + } + + return first; + } + + /// Arguments of an elf upload of a symbols zip or a directory: --uuid is required by the CLI for this type. + public static List ElfArguments(string path, string version, string build, string uuid) + { + var args = new List + { + "debug-files", "upload", path, + "--type", "elf", + "--version", version, + "--build", build, + }; + args.Add("--uuid"); + args.Add(uuid); + return args; + } + /// Extra debug-files upload --type proguard arguments: the id (always explicit) and the launcher icon. public static List MappingArguments(string uuid, string icon) { diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs index ce78d39..3c425d7 100644 --- a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -37,11 +37,11 @@ public static string Snippet() => string.Join("\n", new[] Begin, "// Written by the Bugsee Unity package: the Bugsee Gradle plugin cannot run on this Android Gradle plugin.", "abstract class BugseeResolveBuildIdTask extends DefaultTask {", - " @InputFile @Optional @PathSensitive(PathSensitivity.NONE)", + " @org.gradle.api.tasks.InputFile @org.gradle.api.tasks.Optional @org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.NONE)", " abstract RegularFileProperty getMappingFile()", - " @OutputFile", + " @org.gradle.api.tasks.OutputFile", " abstract RegularFileProperty getIdFile()", - " @TaskAction", + " @org.gradle.api.tasks.TaskAction", " void run() {", " def mapping = mappingFile.orNull?.asFile", " def out = idFile.get().asFile", @@ -50,11 +50,11 @@ public static string Snippet() => string.Join("\n", new[] " }", "}", "abstract class BugseeBuildIdAssetTask extends DefaultTask {", - " @InputFile @PathSensitive(PathSensitivity.NONE)", + " @org.gradle.api.tasks.InputFile @org.gradle.api.tasks.PathSensitive(org.gradle.api.tasks.PathSensitivity.NONE)", " abstract RegularFileProperty getIdFile()", - " @OutputDirectory", + " @org.gradle.api.tasks.OutputDirectory", " abstract DirectoryProperty getOutputDir()", - " @TaskAction", + " @org.gradle.api.tasks.TaskAction", " void run() {", " def dst = outputDir.get().asFile", " dst.deleteDir()", diff --git a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs index 93f141a..69ef034 100644 --- a/Runtime/WrapperPolicy/GradleTemplateDefaults.cs +++ b/Runtime/WrapperPolicy/GradleTemplateDefaults.cs @@ -108,13 +108,17 @@ public static bool AgpSupportsBuildIdBlock(string rootBuildFile) /// /// The versionCode / versionName of the generated launcher build.gradle's defaultConfig: what the /// APK carries, which can differ from Player Settings (split APK version codes, an override). Each is null when - /// the file does not state it as a plain literal. + /// the file does not state it as a plain literal. The version code is also null when the launcher overrides it per + /// output (versionCodeOverride, what Unity adds for split-APK version codes through the + /// **SPLITS_VERSION_CODE** token): the literal is then not what each APK carries. /// public static void ReadGradleVersion(string launcherGradle, out string versionName, out string versionCode) { Match code = launcherGradle == null ? Match.Empty : GradleVersionCode.Match(launcherGradle); Match name = launcherGradle == null ? Match.Empty : GradleVersionName.Match(launcherGradle); - versionCode = code.Success ? code.Groups["v"].Value : null; + versionCode = code.Success && launcherGradle.IndexOf("versionCodeOverride", StringComparison.Ordinal) < 0 + ? code.Groups["v"].Value + : null; versionName = name.Success ? name.Groups["v"].Value : null; } diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs index eb7504d..15f714b 100644 --- a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -93,5 +93,77 @@ public void A_version_the_launcher_does_not_state_as_a_literal_stays_unknown() GradleTemplateDefaults.ReadGradleVersion(null, out name, out code); Assert.That(code, Is.Null); } + + [Test] + public void Split_apks_that_all_carry_the_same_id_give_that_id() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, App.ToUpperInvariant() }, out var conflict); + + Assert.That(id, Is.EqualTo(App)); + Assert.That(conflict, Is.Null); + } + + [Test] + public void Split_apks_with_different_ids_give_none_and_a_conflict() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, "11111111-1111-1111-1111-111111111111" }, out var conflict); + + Assert.That(id, Is.Null); + Assert.That(conflict, Does.Contain("different ids")); + Assert.That(AndroidUploadIdentity.Decide(null, id, Synth, conflict).Warning, Does.Contain("different ids")); + } + + [Test] + public void Split_apks_where_only_some_carry_an_id_warn_but_use_it() + { + var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, null }, out var conflict); + + Assert.That(id, Is.EqualTo(App)); + Assert.That(conflict, Does.Contain("some have none")); + } + + [Test] + public void No_apks_or_none_with_an_id_is_not_a_conflict() + { + Assert.That(AndroidUploadIdentity.SelectAppAssetId(new string[0], out var c1), Is.Null); + Assert.That(c1, Is.Null); + Assert.That(AndroidUploadIdentity.SelectAppAssetId(new string[] { null, "" }, out var c2), Is.Null); + Assert.That(c2, Is.Null); + } + + [Test] + public void Every_elf_upload_carries_the_uuid_the_cli_requires() + { + foreach (var path in new[] { "/o/symbols.zip", "/o/Il2CppOutputProject/libs" }) + { + var args = AndroidUploadIdentity.ElfArguments(path, "0.1.0", "2", App); + + Assert.That(args, Is.EqualTo(new[] + { + "debug-files", "upload", path, "--type", "elf", "--version", "0.1.0", "--build", "2", "--uuid", App, + })); + } + } + + [Test] + public void A_build_uuid_that_is_not_a_uuid_is_ignored_with_a_warning() + { + var id = AndroidUploadIdentity.Decide("my-build-7", App, Synth); + + Assert.That(id.Uuid, Is.EqualTo(App)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); + Assert.That(id.Warning, Does.Contain("BUGSEE_BUILD_UUID is not a UUID")); + } + + [Test] + public void A_launcher_that_overrides_the_version_code_per_output_does_not_state_one() + { + string gradle = "defaultConfig {\n versionCode 2\n versionName '0.1.0'\n}\nandroid.applicationVariants.all { v -> v.outputs.each { it.versionCodeOverride = 3 } }\n"; + + GradleTemplateDefaults.ReadGradleVersion(gradle, out var name, out var code); + + Assert.That(name, Is.EqualTo("0.1.0")); + Assert.That(code, Is.Null, "the literal is not what each APK carries"); + } } } diff --git a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs index bf85e19..898bc0e 100644 --- a/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs +++ b/Tests~/WrapperPolicy/GradleBuildIdBlockTests.cs @@ -104,6 +104,19 @@ public void The_block_text_keeps_the_properties_checked_by_the_real_builds() Assert.That(snippet.Split('\n').Count(l => l.StartsWith("import ")), Is.Zero); } + [Test] + public void Annotations_are_fully_qualified_so_a_launcher_import_cannot_shadow_them() + { + // With Split APKs Unity's launcher imports com.android.build.OutputFile, which broke an unqualified @OutputFile. + foreach (var line in GradleBuildIdBlock.Snippet().Split('\n')) + { + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(line, @"@(\w+(\.\w+)*)")) + { + Assert.That(m.Groups[1].Value, Does.StartWith("org.gradle.api.tasks."), line); + } + } + } + [TestCase("id 'com.android.application' version '7.4.2' apply false", true)] [TestCase("id 'com.android.application' version '7.4.0' apply false", true)] [TestCase("id 'com.android.application' version '7.3.1' apply false", false)] diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index 780fef1..8ed463c 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -74,6 +74,10 @@ public void The_symbol_upload_keys_mapping_and_native_uploads_by_the_id_read_bac Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid")); + + var elf = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadSymbolsZipOrElf("); + Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid)")); + Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(dir, version, build, buildUuid)")); } [Test] @@ -82,7 +86,7 @@ public void The_identity_is_read_from_the_apk_asset_before_the_synthesized_id() var source = Source("Editor/BugseeBuildIdentity.cs"); var body = Body(source, "public static UploadIdentity ResolveAndroidUploadIdentity("); - Assert.That(body, Does.Contain("ReadAppAssetId(report?.summary.outputPath)")); + Assert.That(body, Does.Contain("ReadAppAssetIds(report?.summary.outputPath)")); Assert.That(source, Does.Not.Contain("build-id.txt"), "Gradle's id file can belong to an earlier build"); Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); Assert.That(body, Does.Contain("GradleTemplateDefaults.ReadGradleVersion(")); From 8c26047e24700ba36c8a8fb0e3a5ff39fc6c6c21 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:23:16 +0500 Subject: [PATCH 154/157] Do not pick one id when only some split APKs carry a build id. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Runtime/WrapperPolicy/AndroidUploadIdentity.cs | 6 +++--- Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs index 7d7eb6b..572916a 100644 --- a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -133,8 +133,8 @@ static UploadIdentity DecideValid(string env, string appAssetId, string synthesi /// /// The id of an app delivered as several APKs (Split APKs by target architecture): the one id they all carry. Null - /// when none carries one; says so when they differ or only some carry it, because - /// one upload cannot match both. + /// when none carries one, and also when they differ or only some carry it ( says + /// so): no single id matches them all, so none is picked from the ones that happen to have it. /// public static string SelectAppAssetId(IList idsPerApk, out string conflict) { @@ -168,7 +168,7 @@ public static string SelectAppAssetId(IList idsPerApk, out string confli { conflict = "The APKs of this build do not all carry the same build id (" + (differ ? "different ids" : "some have none") + "): crashes from some of them will not match the uploaded mapping."; - return differ ? null : first; + return null; } return first; diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs index 15f714b..36cbd7e 100644 --- a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -114,11 +114,11 @@ public void Split_apks_with_different_ids_give_none_and_a_conflict() } [Test] - public void Split_apks_where_only_some_carry_an_id_warn_but_use_it() + public void Split_apks_where_only_some_carry_an_id_give_none_and_a_conflict() { var id = AndroidUploadIdentity.SelectAppAssetId(new[] { App, null }, out var conflict); - Assert.That(id, Is.EqualTo(App)); + Assert.That(id, Is.Null, "the APKs without an asset report the synthesized id, so the first one's id is no better"); Assert.That(conflict, Does.Contain("some have none")); } From ae83e60bcc77a791b7af12a11e38ad6f5f3a9d3c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:35:46 +0500 Subject: [PATCH 155/157] Third review round: mapping keyed by the app's id, session-wide download failure, split-APK freshness, guards and docs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BUGSEE_BUILD_UUID no longer overrides the id the app reports for the mapping (native upload only); CHANGELOG notes it. - Download failure kept in SessionState (survives domain reloads); credentials stripped from logged URLs; a non-running cached binary is replaced; routine resolution notes are logs, not warnings. - Split APKs: ignore APKs older than the build and other players' (Game..apk only). - Android upload body and launcher read guarded: never fail the build. - Docs: pinned hashes, linemap lookup, tokenless block, Windows unverified; entries moved to Added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 6 +- Documentation~/index.md | 9 +-- Editor/BugseeBuildIdentity.cs | 35 +++++++--- Editor/BugseeCliLocator.cs | 42 +++++++---- Editor/BugseeSymbolUpload.cs | 12 +++- .../WrapperPolicy/AndroidUploadIdentity.cs | 70 ++++++++++--------- Runtime/WrapperPolicy/BugseeCliPolicy.cs | 12 ++++ Runtime/WrapperPolicy/GradleBuildIdBlock.cs | 5 +- .../AndroidUploadIdentityTests.cs | 58 ++++++++++----- Tests~/WrapperPolicy/BugseeCliPolicyTests.cs | 12 ++++ .../OldUnitySymbolUploadSourceTests.cs | 4 +- 11 files changed, 181 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491d73c..e8421b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Documented the secure-rectangle origin: `AddSecureRectangle`/`RemoveSecureRectangle` take a top-left-origin pixel rectangle on both platforms and pass it to native unchanged (no flip from Unity's bottom-left screen space). Behaviour is unchanged; the docs now include a conversion helper. - Documented what `Launch`, `Relaunch`, `Stop` and `DeleteCollectedDataOnDevice` guarantee on each platform, the 10 s soft and 60 s hard deadlines, and the residual races: iOS, a late native stop (or wipe) can still affect a `Launch` made after the 60 s give-up; Android, a late launch/relaunch result after the hard reset is ignored when no stop intent is set, a queued `Launch` can run while a native delete is still wiping after the reset, and a delete handed to native can finish after its 10 s warning. @@ -31,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. +- Breaking for CI setups: `BUGSEE_BUILD_UUID` keeps keying the native (ELF) upload as before, but the mapping upload is now keyed by the id the built app carries and `BUGSEE_BUILD_UUID` only applies to it when the app has no such asset (and is a UUID). A CI that set a random GUID per build no longer moves the mapping off the id the app reports. + - EXPERIMENTAL, Android only, off by default: `Bugsee.Capture.BugseeFrameCapturer.Enable()` pushes Unity's own screen to the SDK through `VideoMode.DirectBuffers` and `VideoFrameConsumer.addFrame` (`ScreenCapture.CaptureScreenshotIntoRenderTexture` + `AsyncGPUReadback`, one native copy into a reused buffer, masked in place and wrapped with `AndroidJNI.NewDirectByteBuffer`: no Java array; the wrapper writes no frame to disk, the SDK still writes its recorded video), one request in flight with frames dropped meanwhile, throttled to `TargetFps` (default 5). **Supported on release builds of Unity 2022.2.0f1 and newer (2022.3 LTS, 6000.x); not on 2021.3 or 2022.1** (`AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11; alphas and betas are treated as unsupported): there `Enable()` returns false with one warning and the SDK's own capture stays in effect. A launch that asks for `VideoMode.DirectBuffers` starts the capturer itself, and whenever it cannot run (old Unity, no Screen Capture module, no AsyncGPUReadback, a graphics API other than OpenGLES3 or Vulkan) the wrapper replaces `DirectBuffers` with the default capture (`VideoMode.V2`) before the native SDK launches, with one warning, and if the capturer cannot start after the launch was accepted (or stops itself later) it relaunches with the default capture, unless a newer Launch/Relaunch is waiting; it never launches `DirectBuffers` without a frame source. It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (registered at capture start or at readback); pixelation and secure views are not applied; blackout is honoured by the SDK. The readback row order was measured for Vulkan (Galaxy Tab S5e) and OpenGLES3 (emulator), both on Unity 6000.0.84f1. On any other graphics API the row order is unverified: a launch falls back to the default capture, and a capturer started directly with `Enable()` fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. Measured once on a Galaxy Tab S5e (Vulkan, light sample scene): it is not lighter than the SDK's default capture (same frame rate and about the same CPU, about 15 MB more PSS). Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). - `Bugsee.GetLaunchOptions()` (Android and iOS): the effective launch options (SDK defaults merged with the host's) as a read-only dictionary, or `null` while the SDK is not launched. Keys are the native SDKs' own option names. - `Bugsee.Notify(title, body, severity, fields, urgent)` (Android and iOS): relays a lightweight notification to the app's messaging integrations without creating a report. An empty title is ignored with a warning. diff --git a/Documentation~/index.md b/Documentation~/index.md index f9aaf38..9321edd 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -128,11 +128,11 @@ IL2CPP **C# file/line** via LNM always needs uploaded `il2cpp-linemap` + matchin Set `BUGSEE_APP_TOKEN` in the environment (opt-in). Optionally `BUGSEE_CLI_PATH`, `BUGSEE_VERSION`, `BUGSEE_BUILD`, `BUGSEE_IL2CPP_MAPPING`, `BUGSEE_IL2CPP_UUIDS`, `BUGSEE_BUILD_UUID`. -### Where `bugsee-cli` comes from (Android Editor uploads) +### Where `bugsee-cli` comes from (Editor uploads) -A Unity Editor started from the Hub or the Finder does not inherit your shell's `PATH`, so a CLI installed with Homebrew or `install.sh` is often invisible to it. The Editor uploads therefore look, in order, at: `BUGSEE_CLI_PATH`; the Editor's `PATH`; the places the installers use (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`); the package's cache (`~/Library/Caches/Bugsee/cli` on macOS, `$XDG_CACHE_HOME/bugsee/cli` or `~/.cache/bugsee/cli` on Linux, `%LOCALAPPDATA%\Bugsee\cache\cli` on Windows, else `Library/Bugsee/cli` in the project). An installed CLI older than 0.8.0 is passed over. +This lookup serves every Editor upload on any platform that uses the CLI from the Editor (the Android mapping and symbols, and the IL2CPP linemap upload of a standalone or Android build). A Unity Editor started from the Hub or the Finder does not inherit your shell's `PATH`, so a CLI installed with Homebrew or `install.sh` is often invisible to it. The Editor uploads therefore look, in order, at: `BUGSEE_CLI_PATH`; the Editor's `PATH`; the places the installers use (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`); the package's cache (`~/Library/Caches/Bugsee/cli` on macOS, `$XDG_CACHE_HOME/bugsee/cli` or `~/.cache/bugsee/cli` on Linux, `%LOCALAPPDATA%\Bugsee\cache\cli` on Windows, else `Library/Bugsee/cli` in the project). An installed CLI older than 0.8.0 is passed over. -When none is found and a token is set, the pinned release (0.8.1, the Bugsee Gradle plugin's default) is downloaded from `https://download.bugsee.com/cli/v/bugsee-cli-.tar.xz` (`.zip` on Windows), checked against the published `.sha256`, unpacked into that cache and run once to prove it works. The checksum comes from the same host as the archive, so it detects corruption, not a compromised host: set `BUGSEE_CLI_PATH` to a binary you vetted if that matters. `BUGSEE_NO_CLI_DOWNLOAD=1` turns the download off, `BUGSEE_CLI_VERSION` pins another release and `BUGSEE_CLI_BASE_URL` points at a mirror (https only), as for `install.sh`. If nothing can be found or downloaded the build warns once, naming what was tried, and continues without uploading. The iOS Run Script still starts a bare `bugsee-cli` (or `BUGSEE_CLI_PATH`) and does not use this lookup. +When none is found and a token is set, the pinned release (0.8.1, the Bugsee Gradle plugin's default) is downloaded from `https://download.bugsee.com/cli/v/bugsee-cli-.tar.xz` (`.zip` on Windows), checked against the SHA-256 pinned in the package for that release (a release you pin with `BUGSEE_CLI_VERSION` is checked against the published `.sha256` only), unpacked into that cache and run once to prove it works. For a release pinned in the package the checksum does not depend on the download host; for any other it comes from the same host as the archive, so it detects corruption, not a compromised host: set `BUGSEE_CLI_PATH` to a binary you vetted if that matters. Credentials in `BUGSEE_CLI_BASE_URL` are not logged. Not verified on Windows (the zip and `.exe` paths are covered by unit tests only). `BUGSEE_NO_CLI_DOWNLOAD=1` turns the download off, `BUGSEE_CLI_VERSION` pins another release and `BUGSEE_CLI_BASE_URL` points at a mirror (https only), as for `install.sh`. If nothing can be found or downloaded the build warns once, naming what was tried, and continues without uploading. The iOS Run Script still starts a bare `bugsee-cli` (or `BUGSEE_CLI_PATH`) and does not use this lookup. The iOS Run Script uploads for **every** Xcode configuration (Debug included, so a crash from a development build is symbolicated) and runs on every build that has the token in its environment, not only archives. Set `BUGSEE_SKIP_UPLOAD_ON_DEBUG=1` to skip the Debug configuration, for example for local Xcode runs. @@ -169,7 +169,7 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle | Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | | Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | -The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; split-APK version-code schemes and `versionNameSuffix` set outside Unity can still differ: set `BUGSEE_BUILD_UUID` then). `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding (and removes an earlier) asset block, to use the derived id only. With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. +The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; a `versionNameSuffix` or version code changed outside Unity's generated launcher can still differ, and then a mapping uploaded under the derived id will not match; only the asset avoids that). `BUGSEE_BUILD_UUID` keys the native upload as before; for the mapping it is used only when the app carries no asset and the value is a UUID, and never overrides the app's own id. The asset block is added on AGP 7.4.x whenever the build is Android, token or not (like the Unity 6 plugin, which is applied regardless); `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding it (and removes an earlier one) and leaves only the derived id. Its Groovy classes are compiled by Gradle for every build, and the part that runs per variant is guarded so a failure there only costs the id; a tokenless 2022.3 release build with R8 on was run to check they do not break Gradle (see the matrix). With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. With Split APKs by target architecture the id is read from every APK; if they disagree (or only some carry one) the build warns and the upload uses the synthesized id. A launcher that overrides the version code per output (`versionCodeOverride`, what Unity's split version-code setting adds) is not trusted for the synthesized id's version code: Player Settings' is used. @@ -183,6 +183,7 @@ Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, u | 2022.3.62f3 | release | on | same, plus emulator native, Java and managed crashes symbolicated | | 2022.3.62f3 | release | on, `BUGSEE_NO_GRADLE_BUILD_ID=1` | builds; no asset; mapping uploaded under the synthesized id with the warning; the Java crash on an emulator grouped with the symbolicated issue | | 2022.3.62f3 | release, Split APKs by target architecture (ARMv7 + ARM64) | on | builds; Unity writes a folder `split.apk/` of per-ABI APKs: both carry the same asset id, equal to the uploaded mapping id; the symbols zip (named after `split`, not `split.apk`) is found and uploaded | +| 2022.3.62f3 | release, no app token | on | builds; the block is added regardless and the asset is in the APK; no upload, no warning | | 2022.3.62f3 | Export Project | on | builds; no native libraries exist in the export, so no ELF upload (the CLI refuses a directory upload without `--uuid`, which every ELF upload now carries; checked with `bugsee-cli --dry-run`) | | 2021.3.45f1 | release | off, development off | builds, as above | | 2021.3.45f1 | release, development | on | builds; APK asset id equals the uploaded mapping id | diff --git a/Editor/BugseeBuildIdentity.cs b/Editor/BugseeBuildIdentity.cs index 0ef8a61..2d6b9e0 100644 --- a/Editor/BugseeBuildIdentity.cs +++ b/Editor/BugseeBuildIdentity.cs @@ -82,14 +82,21 @@ public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) // What the APK carries, when the generated launcher says so plainly (split APK codes, overrides). var root = BugseeAndroidGradleSetup.GeneratedProjectRoot; var launcher = string.IsNullOrEmpty(root) ? null : Path.Combine(root, "launcher", "build.gradle"); - if (launcher != null && File.Exists(launcher)) + try + { + if (launcher != null && File.Exists(launcher)) + { + GradleTemplateDefaults.ReadGradleVersion(File.ReadAllText(launcher), out var gradleName, out var gradleCode); + name = gradleName ?? name; + code = gradleCode ?? code; + } + } + catch (Exception ex) { - GradleTemplateDefaults.ReadGradleVersion(File.ReadAllText(launcher), out var gradleName, out var gradleCode); - name = gradleName ?? name; - code = gradleCode ?? code; + Debug.LogWarning($"Bugsee: could not read the generated launcher's version: {ex.Message}"); } - var appId = AndroidUploadIdentity.SelectAppAssetId(ReadAppAssetIds(report?.summary.outputPath), out var conflict); + var appId = AndroidUploadIdentity.SelectAppAssetId(ReadAppAssetIds(report?.summary.outputPath, report == null ? DateTime.MinValue : report.summary.buildStartedAt.ToUniversalTime()), out var conflict); if (conflict != null) { Debug.LogWarning("Bugsee: " + conflict); @@ -107,7 +114,7 @@ public static UploadIdentity ResolveAndroidUploadIdentity(BuildReport report) /// per ABI (Split APKs by target architecture), where that path is a folder holding them, every APK in it. /// An entry is null when that file carries no id or cannot be read. /// - static List ReadAppAssetIds(string outputPath) + static List ReadAppAssetIds(string outputPath, DateTime buildStartedUtc) { var ids = new List(); if (string.IsNullOrEmpty(outputPath)) @@ -128,7 +135,10 @@ static List ReadAppAssetIds(string outputPath) { foreach (var apk in Directory.GetFiles(outputPath, "*.apk")) { - ids.Add(ReadAppAssetId(apk)); + if (IsFromThisBuild(apk, buildStartedUtc)) + { + ids.Add(ReadAppAssetId(apk)); + } } return ids; @@ -139,9 +149,13 @@ static List ReadAppAssetIds(string outputPath) if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir) && !string.IsNullOrEmpty(stem) && outputPath.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) { - foreach (var apk in Directory.GetFiles(dir, stem + "*.apk")) + foreach (var apk in Directory.GetFiles(dir, "*.apk")) { - ids.Add(ReadAppAssetId(apk)); + // "Game.arm64-v8a.apk" yes; "GameLite.apk" (another player) and APKs of earlier builds no. + if (AndroidUploadIdentity.IsSplitApkOf(Path.GetFileName(apk), stem) && IsFromThisBuild(apk, buildStartedUtc)) + { + ids.Add(ReadAppAssetId(apk)); + } } } } @@ -153,6 +167,9 @@ static List ReadAppAssetIds(string outputPath) return ids; } + static bool IsFromThisBuild(string file, DateTime buildStartedUtc) => + File.GetLastWriteTimeUtc(file) >= buildStartedUtc; + /// The id in one APK/AAB's build id asset, or null (no such asset, not an archive, unreadable). static string ReadAppAssetId(string path) { diff --git a/Editor/BugseeCliLocator.cs b/Editor/BugseeCliLocator.cs index cbf1c26..b5dc07b 100644 --- a/Editor/BugseeCliLocator.cs +++ b/Editor/BugseeCliLocator.cs @@ -27,8 +27,14 @@ internal static class BugseeCliLocator { static CliResolution _resolved; static bool _notFoundReported; - // Kept for the editor session (Reset does not clear it): a blackholed network must cost one timeout, not one per build. - static string _downloadFailure; + // Kept for the editor session, across domain reloads (SessionState; Reset does not clear it): a blackholed network + // must cost one timeout, not one per build. + const string DownloadFailureKey = "Bugsee.CliDownloadFailure"; + static string DownloadFailure + { + get { var value = UnityEditor.SessionState.GetString(DownloadFailureKey, ""); return value.Length == 0 ? null : value; } + set { UnityEditor.SessionState.SetString(DownloadFailureKey, value ?? ""); } + } static string _stagingDir; public static CliResolution Resolve() @@ -59,9 +65,10 @@ public static CliResolution Resolve() if (_resolved.Found) { Debug.Log("[Bugsee] Using bugsee-cli " + (_resolved.Version ?? "(version unknown)") + " (" + Describe(_resolved.Source) + "): " + _resolved.Path); + // Routine when something was found after all (a shim that does not run, an old install passed over). foreach (string note in _resolved.Notes) { - Debug.LogWarning("[Bugsee] " + note); + Debug.Log("[Bugsee] " + note); } } @@ -141,10 +148,10 @@ static CliSettings ReadSettings() DownloadAllowed = BugseeCliPolicy.DownloadAllowed( BugseeCliRunner.HasAppToken, Environment.GetEnvironmentVariable("BUGSEE_NO_CLI_DOWNLOAD") != null), }; - if (_downloadFailure != null) + if (DownloadFailure != null) { settings.DownloadAllowed = false; - settings.DownloadDisabledNote = "Downloading bugsee-cli failed earlier in this Editor session (" + _downloadFailure + "); not retried until it restarts."; + settings.DownloadDisabledNote = "Downloading bugsee-cli failed earlier in this Editor session (" + DownloadFailure + "); not retried until it restarts."; } // Shared by every project of this user first; the project's own Library when that is not writable. @@ -242,7 +249,7 @@ static string Fetch(CliSettings settings, string version) } catch (Exception ex) { - _downloadFailure = ex.Message; + DownloadFailure = ex.Message; DiscardStaging(); throw; } @@ -256,12 +263,12 @@ static string FetchUnchecked(CliSettings settings, string version) string url = BugseeCliPolicy.ArchiveUrl(baseUrl, version, triple); if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - throw new IOException("refusing a non-https download URL " + url); + throw new IOException("refusing a non-https download URL " + BugseeCliPolicy.RedactUrl(url)); } _stagingDir = Path.Combine(Path.GetTempPath(), "bugsee-cli-" + Guid.NewGuid().ToString("N").Substring(0, 8)); Directory.CreateDirectory(_stagingDir); - Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + url); + Debug.Log("[Bugsee] Downloading bugsee-cli " + version + " for " + triple + " from " + BugseeCliPolicy.RedactUrl(url)); string archive = Path.Combine(_stagingDir, BugseeCliPolicy.ArchiveName(triple)); FetchFile(url, archive); @@ -315,9 +322,16 @@ static string Install(CliSettings settings, string staged, string cacheDir, stri string finalBinary = BugseeCliPolicy.CachedBinaryPath(settings, cacheDir, version, settings.Triple); string finalDir = Path.GetDirectoryName(finalBinary); Directory.CreateDirectory(finalDir); - if (File.Exists(finalBinary) && QueryVersion(finalBinary) != null) + if (File.Exists(finalBinary)) { - return finalBinary; + if (QueryVersion(finalBinary) != null) + { + return finalBinary; + } + + // A leftover that does not run (an interrupted install, another machine's architecture) would make the + // move below fail for ever: replace it. + try { File.Delete(finalBinary); } catch { /* the move reports it */ } } string temp = Path.Combine(finalDir, Path.GetFileName(finalBinary) + ".tmp-" + Guid.NewGuid().ToString("N").Substring(0, 8)); @@ -387,7 +401,7 @@ static void FetchFile(string url, string destination) 130000, out ignored); if (exit != 0 || !File.Exists(destination)) { - throw new IOException("GET " + url + " failed (" + ex.Message + "; curl exit " + exit + ")"); + throw new IOException("GET " + BugseeCliPolicy.RedactUrl(url) + " failed (" + ex.Message + "; curl exit " + exit + ")"); } } } @@ -419,7 +433,7 @@ static void FetchWithRedirects(string url, string destination) var next = new Uri(new Uri(current), response.Headers["Location"] ?? ""); if (next.Scheme != Uri.UriSchemeHttps) { - throw new HttpStatusException("refusing a redirect to " + next + " (not https)"); + throw new HttpStatusException("refusing a redirect to " + BugseeCliPolicy.RedactUrl(next.AbsoluteUri) + " (not https)"); } current = next.AbsoluteUri; @@ -428,7 +442,7 @@ static void FetchWithRedirects(string url, string destination) if (status < 200 || status >= 300) { - throw new HttpStatusException("GET " + current + " returned " + status); + throw new HttpStatusException("GET " + BugseeCliPolicy.RedactUrl(current) + " returned " + status); } using (Stream input = response.GetResponseStream()) @@ -441,7 +455,7 @@ static void FetchWithRedirects(string url, string destination) } } - throw new HttpStatusException("too many redirects from " + url); + throw new HttpStatusException("too many redirects from " + BugseeCliPolicy.RedactUrl(url)); } static string Sha256Hex(string path) diff --git a/Editor/BugseeSymbolUpload.cs b/Editor/BugseeSymbolUpload.cs index 4bb37b4..f5d9f8f 100644 --- a/Editor/BugseeSymbolUpload.cs +++ b/Editor/BugseeSymbolUpload.cs @@ -40,7 +40,15 @@ public void OnPostprocessBuild(BuildReport report) switch (report.summary.platform) { case BuildTarget.Android: - UploadAndroidSymbols(report, version, build); + try + { + UploadAndroidSymbols(report, version, build); + } + catch (Exception ex) + { + // Whatever goes wrong here (an unreadable file, a locked folder) must not fail the build. + Debug.LogWarning($"Bugsee: Android symbol upload failed: {ex.Message} — build continues."); + } break; case BuildTarget.iOS: case BuildTarget.tvOS: @@ -91,7 +99,7 @@ static void UploadAndroidSymbols(BuildReport report, string version, string buil UploadIfFound(roots, "*mapping.txt", "proguard", version, build, path => BuildArtifactChoice.MappingRank(path, stem, allowGeneric), mappingExtra, identityWarning); // Unity "Create symbols.zip" / Gradle native debug symbols. - UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid ?? Guid.NewGuid().ToString("D")); + UploadSymbolsZipOrElf(report, roots, version, build, identity.ElfUuid ?? Guid.NewGuid().ToString("D")); } static string FindLauncherIcon() diff --git a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs index 572916a..9b97ec1 100644 --- a/Runtime/WrapperPolicy/AndroidUploadIdentity.cs +++ b/Runtime/WrapperPolicy/AndroidUploadIdentity.cs @@ -18,7 +18,10 @@ public enum BuildIdSource public sealed class UploadIdentity { + /// The id the mapping is keyed by. public string Uuid; + /// The id the native (ELF) upload is keyed by: BUGSEE_BUILD_UUID when set, else . + public string ElfUuid; public BuildIdSource Source; /// Why the id may not match what the app reports; shown when a mapping is uploaded under it. public string Warning; @@ -67,12 +70,17 @@ public static string ParseAssetProperties(string text) } /// - /// Priority: BUGSEE_BUILD_UUID (an explicit choice), the id the app carries, the id the SDK synthesizes. is the one the app reports, so a different BUGSEE_BUILD_UUID is - /// followed but called out. + /// The mapping is keyed by the id the app reports: the id the built app carries, else (no asset) a valid + /// BUGSEE_BUILD_UUID the user set as the id they know the app reports, else the id the SDK synthesizes. An asset + /// always wins over the environment: a value that differs from what the app reports could never match. + /// BUGSEE_BUILD_UUID keeps its older meaning for the native upload (): any + /// non-empty value is that upload's correlation key; without it the mapping's id is used. /// public static UploadIdentity Decide(string env, string appAssetId, string synthesized, string appAssetConflict = null) { - var result = DecideCore(env, appAssetId, synthesized); + var result = DecideMapping(env, appAssetId, synthesized); + string envValue = string.IsNullOrWhiteSpace(env) ? null : env.Trim(); + result.ElfUuid = envValue ?? result.Uuid; if (appAssetConflict != null) { result.Warning = result.Warning == null ? appAssetConflict : appAssetConflict + " " + result.Warning; @@ -81,39 +89,27 @@ public static UploadIdentity Decide(string env, string appAssetId, string synthe return result; } - static UploadIdentity DecideCore(string env, string appAssetId, string synthesized) - { - string invalidEnv = null; - if (!string.IsNullOrWhiteSpace(env) && !IsUuid(env)) - { - invalidEnv = "BUGSEE_BUILD_UUID is not a UUID and is ignored."; - env = null; - } - - var result = DecideValid(env, appAssetId, synthesized); - if (invalidEnv != null) - { - result.Warning = result.Warning == null ? invalidEnv : invalidEnv + " " + result.Warning; - } - - return result; - } - - static UploadIdentity DecideValid(string env, string appAssetId, string synthesized) + static UploadIdentity DecideMapping(string env, string appAssetId, string synthesized) { string app = IsUuid(appAssetId) ? appAssetId.Trim().ToLowerInvariant() : null; - if (!string.IsNullOrWhiteSpace(env)) + string envValue = string.IsNullOrWhiteSpace(env) ? null : env.Trim(); + if (app != null) { - string value = env.Trim(); - string warning = app != null && !string.Equals(app, value, StringComparison.OrdinalIgnoreCase) - ? "BUGSEE_BUILD_UUID (" + value + ") differs from the id the app reports (" + app + "): crashes will not match this upload." + string note = envValue != null && !string.Equals(app, envValue, StringComparison.OrdinalIgnoreCase) + ? "BUGSEE_BUILD_UUID (" + envValue + ") is not the id the app reports (" + app + "); the mapping is uploaded under the app's id and BUGSEE_BUILD_UUID is used for the native upload only." : null; - return new UploadIdentity { Uuid = value, Source = BuildIdSource.Environment, Warning = warning }; + return new UploadIdentity { Uuid = app, Source = BuildIdSource.AppAsset, Warning = note }; } - if (app != null) + string envNote = null; + if (envValue != null) { - return new UploadIdentity { Uuid = app, Source = BuildIdSource.AppAsset }; + if (IsUuid(envValue)) + { + return new UploadIdentity { Uuid = envValue.ToLowerInvariant(), Source = BuildIdSource.Environment }; + } + + envNote = "BUGSEE_BUILD_UUID is not a UUID, so it is not used as the mapping's id (it still keys the native upload)."; } if (synthesized != null) @@ -122,13 +118,23 @@ static UploadIdentity DecideValid(string env, string appAssetId, string synthesi { Uuid = synthesized, Source = BuildIdSource.Synthesized, - Warning = "The built app carries no bugsee_build_id.properties, so the SDK reports the id it derives from the app token, " + + Warning = (envNote == null ? "" : envNote + " ") + + "The built app carries no bugsee_build_id.properties, so the SDK reports the id it derives from the app token, " + "version name and version code; uploading under that id. If the app is built with a different token, version " + - "name or code than Player Settings (split APKs, versionCodeOverride), set BUGSEE_BUILD_UUID or rebuild.", + "name or code than Player Settings, the mapping will not match it.", }; } - return new UploadIdentity { Uuid = null, Source = BuildIdSource.None }; + return new UploadIdentity { Uuid = null, Source = BuildIdSource.None, Warning = envNote }; + } + + /// Whether an APK file name is a sibling of the player "Game.apk": "Game.arm64-v8a.apk", not "GameLite.apk". + public static bool IsSplitApkOf(string fileName, string stem) + { + return !string.IsNullOrEmpty(stem) && fileName != null && + fileName.StartsWith(stem + ".", StringComparison.OrdinalIgnoreCase) && + fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) && + fileName.Length > stem.Length + ".apk".Length + 1; } /// diff --git a/Runtime/WrapperPolicy/BugseeCliPolicy.cs b/Runtime/WrapperPolicy/BugseeCliPolicy.cs index 1a46b3e..6103291 100644 --- a/Runtime/WrapperPolicy/BugseeCliPolicy.cs +++ b/Runtime/WrapperPolicy/BugseeCliPolicy.cs @@ -139,6 +139,18 @@ public static bool IsValidVersion(string version) => version != null && System.Text.RegularExpressions.Regex.IsMatch( version, @"^[0-9]+(\.[0-9]+)*([-+][0-9A-Za-z.]+)?$"); + /// The URL without credentials (https://user:pass@host/...), for logs and messages. + public static string RedactUrl(string url) + { + Uri uri; + if (url == null || !Uri.TryCreate(url, UriKind.Absolute, out uri) || string.IsNullOrEmpty(uri.UserInfo)) + { + return url; + } + + return new UriBuilder(uri) { UserName = "", Password = "" }.Uri.AbsoluteUri; + } + public static bool IsWindowsTriple(string triple) => triple != null && triple.Contains("windows"); public static string BinaryName(bool windows) => windows ? CliName + ".exe" : CliName; diff --git a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs index 3c425d7..ec9aab1 100644 --- a/Runtime/WrapperPolicy/GradleBuildIdBlock.cs +++ b/Runtime/WrapperPolicy/GradleBuildIdBlock.cs @@ -17,8 +17,9 @@ public enum BuildIdBlockStatus /// The part of the Bugsee Gradle plugin that gives the app its build id, for Android Gradle plugins too old to run /// the plugin (Unity 2021.3 / 2022.3 pin AGP 7.4.2; the plugin needs 8.6+). After R8 it writes /// assets/bugsee_build_id.properties holding the UUID of the R8 mapping (MD5 name-based, what the plugin - /// and bugsee-cli --type proguard compute), which is what the SDK reports at runtime. The mapping upload - /// then needs no --uuid: the CLI derives the same id from the file. With no mapping (R8 off) no asset is + /// and bugsee-cli --type proguard compute), which is what the SDK reports at runtime. The upload + /// reads the id back from the built APK and passes it as --uuid (it equals the id the CLI derives from the + /// mapping file). With no mapping (R8 off) no asset is /// written and the SDK derives its own id (). /// /// Uses only API present in AGP 7.4 (artifacts.get(OBFUSCATION_MAPPING_FILE) and a generated assets source diff --git a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs index 36cbd7e..9de31a7 100644 --- a/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs +++ b/Tests~/WrapperPolicy/AndroidUploadIdentityTests.cs @@ -27,19 +27,53 @@ public void With_no_asset_the_sdks_own_id_is_used_and_the_warning_says_why() Assert.That(id.Uuid, Is.EqualTo(Synth)); Assert.That(id.Source, Is.EqualTo(BuildIdSource.Synthesized)); - Assert.That(id.Warning, Does.Contain("no bugsee_build_id.properties").And.Contain("BUGSEE_BUILD_UUID")); + Assert.That(id.Warning, Does.Contain("no bugsee_build_id.properties").And.Contain("will not match")); } [Test] - public void An_environment_override_wins_and_a_conflict_with_the_app_is_called_out() + public void The_apps_id_keys_the_mapping_and_the_environment_value_keys_only_the_native_upload() { - var agrees = AndroidUploadIdentity.Decide(App.ToUpperInvariant(), App, Synth); - var conflicts = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", App, Synth); + // A CI that sets a fresh GUID per build must not move the mapping off the id the app reports. + var id = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", App, Synth); + + Assert.That(id.Uuid, Is.EqualTo(App)); + Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); + Assert.That(id.ElfUuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(id.Warning, Does.Contain("native upload only")); + } - Assert.That(agrees.Source, Is.EqualTo(BuildIdSource.Environment)); + [Test] + public void The_native_upload_follows_the_mapping_when_the_environment_is_unset_or_agrees() + { + Assert.That(AndroidUploadIdentity.Decide(null, App, Synth).ElfUuid, Is.EqualTo(App)); + var agrees = AndroidUploadIdentity.Decide(App.ToUpperInvariant(), App, Synth); Assert.That(agrees.Warning, Is.Null); - Assert.That(conflicts.Uuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); - Assert.That(conflicts.Warning, Does.Contain(App).And.Contain("will not match")); + Assert.That(agrees.Uuid, Is.EqualTo(App)); + } + + [Test] + public void Without_an_asset_a_valid_environment_uuid_is_the_mapping_id_and_any_value_keys_the_native_upload() + { + var uuid = AndroidUploadIdentity.Decide("11111111-1111-1111-1111-111111111111", null, Synth); + var free = AndroidUploadIdentity.Decide("my-build-7", null, Synth); + + Assert.That(uuid.Uuid, Is.EqualTo("11111111-1111-1111-1111-111111111111")); + Assert.That(uuid.Source, Is.EqualTo(BuildIdSource.Environment)); + Assert.That(free.Uuid, Is.EqualTo(Synth), "a non-UUID cannot be the mapping's id"); + Assert.That(free.ElfUuid, Is.EqualTo("my-build-7"), "it still keys the native upload, as before"); + Assert.That(free.Warning, Does.Contain("not a UUID")); + } + + [TestCase("Game.arm64-v8a.apk", "Game", true)] + [TestCase("game.ARMEABI-V7A.APK", "Game", true)] + [TestCase("GameLite.apk", "Game", false)] + [TestCase("Game.apk", "Game", false)] + [TestCase("Game..apk", "Game", false)] + [TestCase("Game.arm64-v8a.aab", "Game", false)] + [TestCase("Game.arm64-v8a.apk", "", false)] + public void Only_a_players_own_split_apks_are_siblings(string file, string stem, bool expected) + { + Assert.That(AndroidUploadIdentity.IsSplitApkOf(file, stem), Is.EqualTo(expected)); } [Test] @@ -145,16 +179,6 @@ public void Every_elf_upload_carries_the_uuid_the_cli_requires() } } - [Test] - public void A_build_uuid_that_is_not_a_uuid_is_ignored_with_a_warning() - { - var id = AndroidUploadIdentity.Decide("my-build-7", App, Synth); - - Assert.That(id.Uuid, Is.EqualTo(App)); - Assert.That(id.Source, Is.EqualTo(BuildIdSource.AppAsset)); - Assert.That(id.Warning, Does.Contain("BUGSEE_BUILD_UUID is not a UUID")); - } - [Test] public void A_launcher_that_overrides_the_version_code_per_output_does_not_state_one() { diff --git a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs index 965b78a..0b86615 100644 --- a/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs +++ b/Tests~/WrapperPolicy/BugseeCliPolicyTests.cs @@ -362,6 +362,18 @@ public void Only_release_versions_may_reach_a_path_or_url(string version, bool e Assert.That(BugseeCliPolicy.IsValidVersion(version), Is.EqualTo(expected)); } + [TestCase("https://user:secret@mirror.example/cli", "https://mirror.example/cli")] + [TestCase("https://mirror.example/cli/v0.8.1/x.tar.xz", "https://mirror.example/cli/v0.8.1/x.tar.xz")] + [TestCase("not a url", "not a url")] + [TestCase(null, null)] + public void Credentials_are_removed_from_urls_before_they_are_logged(string url, string expected) + { + var redacted = BugseeCliPolicy.RedactUrl(url); + + Assert.That(redacted == null ? null : redacted.TrimEnd('/'), Is.EqualTo(expected == null ? null : expected.TrimEnd('/'))); + Assert.That(redacted ?? "", Does.Not.Contain("secret")); + } + [Test] public void Cache_layout_is_version_then_triple() { diff --git a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs index 8ed463c..ef20399 100644 --- a/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs +++ b/Tests~/WrapperPolicy/OldUnitySymbolUploadSourceTests.cs @@ -73,7 +73,7 @@ public void The_symbol_upload_keys_mapping_and_native_uploads_by_the_id_read_bac Assert.That(body, Does.Contain("BugseeBuildIdentity.ResolveAndroidUploadIdentity(report)")); Assert.That(body, Does.Contain("AndroidUploadIdentity.MappingArguments(identity.Uuid, icon)")); - Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.Uuid")); + Assert.That(body, Does.Contain("UploadSymbolsZipOrElf(report, roots, version, build, identity.ElfUuid")); var elf = Body(Source("Editor/BugseeSymbolUpload.cs"), "static void UploadSymbolsZipOrElf("); Assert.That(elf, Does.Contain("AndroidUploadIdentity.ElfArguments(bestZip, version, build, buildUuid)")); @@ -86,7 +86,7 @@ public void The_identity_is_read_from_the_apk_asset_before_the_synthesized_id() var source = Source("Editor/BugseeBuildIdentity.cs"); var body = Body(source, "public static UploadIdentity ResolveAndroidUploadIdentity("); - Assert.That(body, Does.Contain("ReadAppAssetIds(report?.summary.outputPath)")); + Assert.That(body, Does.Contain("ReadAppAssetIds(report?.summary.outputPath,")); Assert.That(source, Does.Not.Contain("build-id.txt"), "Gradle's id file can belong to an earlier build"); Assert.That(body, Does.Contain("AndroidBuildUuid.Synthesize(")); Assert.That(body, Does.Contain("GradleTemplateDefaults.ReadGradleVersion(")); From a7b46a1538e443846a01774daf0dc4a71869ba2a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:42:35 +0500 Subject: [PATCH 156/157] CHANGELOG/docs: state the mapping and native upload ids separately. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 2 +- Documentation~/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8421b0..3cd7833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Editor symbol uploads on Android now find `bugsee-cli` when the Unity Editor's `PATH` lacks it. Before, a Hub-started Editor could not start a bare `bugsee-cli` (`Cannot find the specified file`) and nothing was uploaded. The lookup is `BUGSEE_CLI_PATH`, `PATH`, the installers' directories (`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/.cargo/bin`, `~/.bugsee/bin`, `%LOCALAPPDATA%\Bugsee\bin`), a cache, then a download of bugsee-cli 0.8.1 from download.bugsee.com when a token is set. The default release's archives are checked against SHA-256 values pinned in the package (other versions against the published `.sha256`), the download is one bounded attempt (https only, also across redirects) and a failure is remembered for the Editor session, and the install into the shared cache is atomic. `BUGSEE_NO_CLI_DOWNLOAD=1` opts out; `BUGSEE_CLI_VERSION` (not below 0.8.0) and `BUGSEE_CLI_BASE_URL` override. A CLI older than 0.8.0 is passed over. Not found: one warning, the build continues. The iOS Run Script is unchanged. Verified with real IL2CPP arm64 release builds on 2022.3.62f3 (download path, PATH without the CLI) and 2021.3.45f1. -- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping and the native symbols under it, with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the upload uses that, with a warning. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. +- Unity 2021.3 / 2022.3 (AGP 7.4.2) lose the Bugsee Gradle plugin, which needs AGP 8.6+. The part of it that gives the app its build id is recovered for AGP 7.4.x (the only line it was run on): the launcher gets a small Gradle block that writes `assets/bugsee_build_id.properties` from the R8 mapping (same UUID the plugin and the CLI compute). The upload no longer assumes the block worked: it reads the id back from the built APK/AAB (every APK when Unity writes one per ABI, which with Split APKs by target architecture sits in a folder named like the player; a disagreement is a warning) and uploads the mapping under it, and the native symbols under it too unless `BUGSEE_BUILD_UUID` is set (see the next entry), with `--uuid` always explicit (the native upload of an exported project's libraries included; it was missing there and the CLI refuses an `elf` upload without it). The symbols zip of a Split APKs build, named after the player without `.apk`, is now found too. If the app carries no such asset (R8 off, another AGP, a build script that replaced the assets) the SDK derives its own id from token, version name and version code (read from the generated launcher, which is what the APK carries) and the mapping is uploaded under that, with a warning, unless `BUGSEE_BUILD_UUID` holds a UUID, which is then used for the mapping instead. The mapping upload also attaches the launcher icon, as the plugin does. Still not recovered on these versions: the plugin's bytecode instrumentation, SDK-extension provider stripping, and build-info/size-analysis upload (the plugin skips the latter in Unity too, finding no app token). Details and the build matrix in `Documentation~/index.md`. - Breaking for CI setups: `BUGSEE_BUILD_UUID` keeps keying the native (ELF) upload as before, but the mapping upload is now keyed by the id the built app carries and `BUGSEE_BUILD_UUID` only applies to it when the app has no such asset (and is a UUID). A CI that set a random GUID per build no longer moves the mapping off the id the app reports. - EXPERIMENTAL, Android only, off by default: `Bugsee.Capture.BugseeFrameCapturer.Enable()` pushes Unity's own screen to the SDK through `VideoMode.DirectBuffers` and `VideoFrameConsumer.addFrame` (`ScreenCapture.CaptureScreenshotIntoRenderTexture` + `AsyncGPUReadback`, one native copy into a reused buffer, masked in place and wrapped with `AndroidJNI.NewDirectByteBuffer`: no Java array; the wrapper writes no frame to disk, the SDK still writes its recorded video), one request in flight with frames dropped meanwhile, throttled to `TargetFps` (default 5). **Supported on release builds of Unity 2022.2.0f1 and newer (2022.3 LTS, 6000.x); not on 2021.3 or 2022.1** (`AndroidJNI.NewDirectByteBuffer` first appears in 2022.2.0a11; alphas and betas are treated as unsupported): there `Enable()` returns false with one warning and the SDK's own capture stays in effect. A launch that asks for `VideoMode.DirectBuffers` starts the capturer itself, and whenever it cannot run (old Unity, no Screen Capture module, no AsyncGPUReadback, a graphics API other than OpenGLES3 or Vulkan) the wrapper replaces `DirectBuffers` with the default capture (`VideoMode.V2`) before the native SDK launches, with one warning, and if the capturer cannot start after the launch was accepted (or stops itself later) it relaunches with the default capture, unless a newer Launch/Relaunch is waiting; it never launches `DirectBuffers` without a frame source. It captures Unity's final composite including screen-space overlay UI but not native Android views. Secure rectangles are blanked in the pushed frames (registered at capture start or at readback); pixelation and secure views are not applied; blackout is honoured by the SDK. The readback row order was measured for Vulkan (Galaxy Tab S5e) and OpenGLES3 (emulator), both on Unity 6000.0.84f1. On any other graphics API the row order is unverified: a launch falls back to the default capture, and a capturer started directly with `Enable()` fails closed (fully black frames while a secure rectangle is registered) until `BugseeFrameCapturerOptions.RowOrder` is set. Needs the `com.unity.modules.screencapture` module. Measured once on a Galaxy Tab S5e (Vulkan, light sample scene): it is not lighter than the SDK's default capture (same frame rate and about the same CPU, about 15 MB more PSS). Nothing changes unless you call `Enable()`. Refs #22 (benchmarking against the SDK's default capture on more devices is outstanding). diff --git a/Documentation~/index.md b/Documentation~/index.md index 9321edd..9099939 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -169,7 +169,7 @@ Both ship AGP 7.4.2 / Gradle 7.5.1; Gradle plugin 4.0.8 needs AGP 8.6+ / Gradle | Bytecode instrumentation (OkHttp, HttpEngine, `android.util.Log`, thread, main-thread misuse, operation dispatch, Compose, startup tracing), SDK-extension provider stripping, Compose compiler plugin | Plugin | **Not recovered** (needs AGP 8.6+). They act on the app module's own JVM bytecode, which in a Unity app is a launcher stub, so little is lost, but a Java/Kotlin plugin of yours that uses OkHttp or `HttpEngine` is not auto-captured on these Unity versions | | Build-info / size-analysis upload, dependency and timing collection | Plugin (skips, no token) | **Not recovered** | -The mapping and native uploads of an Android build are keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; a `versionNameSuffix` or version code changed outside Unity's generated launcher can still differ, and then a mapping uploaded under the derived id will not match; only the asset avoids that). `BUGSEE_BUILD_UUID` keys the native upload as before; for the mapping it is used only when the app carries no asset and the value is a UUID, and never overrides the app's own id. The asset block is added on AGP 7.4.x whenever the build is Android, token or not (like the Unity 6 plugin, which is applied regardless); `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding it (and removes an earlier one) and leaves only the derived id. Its Groovy classes are compiled by Gradle for every build, and the part that runs per variant is guarded so a failure there only costs the id; a tokenless 2022.3 release build with R8 on was run to check they do not break Gradle (see the matrix). With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. +The mapping upload of an Android build is keyed by the id the built app reports: the APK/AAB's `bugsee_build_id.properties` if it has one, else (when `BUGSEE_BUILD_UUID` is not a UUID) the id the SDK derives from token, version name and version code (from the generated launcher, which is what the APK carries; a `versionNameSuffix` or version code changed outside Unity's generated launcher can still differ, and then a mapping uploaded under the derived id will not match; only the asset avoids that). The native upload uses the same id unless `BUGSEE_BUILD_UUID` is set, which keys it as before (any non-empty value); for the mapping `BUGSEE_BUILD_UUID` is used only when the app carries no asset and the value is a UUID, and never overrides the app's own id. The asset block is added on AGP 7.4.x whenever the build is Android, token or not (like the Unity 6 plugin, which is applied regardless); `BUGSEE_NO_GRADLE_BUILD_ID=1` stops the package adding it (and removes an earlier one) and leaves only the derived id. Its Groovy classes are compiled by Gradle for every build, and the part that runs per variant is guarded so a failure there only costs the id; a tokenless 2022.3 release build with R8 on was run to check they do not break Gradle (see the matrix). With the derived id, a rebuild with the same token, version name and code but different R8 output re-uses the id: the server keeps the first mapping uploaded under it, so bump the version code for a minified build whose obfuscation changed. With Split APKs by target architecture the id is read from every APK; if they disagree (or only some carry one) the build warns and the upload uses the synthesized id. A launcher that overrides the version code per output (`versionCodeOverride`, what Unity's split version-code setting adds) is not trusted for the synthesized id's version code: Player Settings' is used. From b0bc2284f06668cd777aa680f2260b3a42667dfb Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 21:43:59 +0500 Subject: [PATCH 157/157] Apply the wrapper's R8 keep rules to Minify builds and cover every class reached over JNI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unity does not read Plugins/Android/bugsee-unity-wrapper.proguard from a package, so with Minify enabled R8 stripped UnityWrapper and UnityManagedException and the SDK failed to launch (ClassNotFoundException). BugseeAndroidProguardRules appends the rules to the generated unityLibrary/proguard-unity.txt (consumer rules) between markers, idempotently. The rules also keep UnityManagedException, UnityWrapperIdentity and the AndroidJavaProxy interfaces; tests fail when a class C# names over JNI has no rule. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 1 + Documentation~/index.md | 3 +- Editor/BugseeAndroidProguardRules.cs | 79 +++++++++ Editor/BugseeAndroidProguardRules.cs.meta | 11 ++ Editor/BugseeAndroidWrapperManifest.cs | 6 +- Plugins/Android/bugsee-unity-wrapper.proguard | 23 ++- Runtime/WrapperPolicy/ProguardRules.cs | 53 ++++++ Runtime/WrapperPolicy/ProguardRules.cs.meta | 11 ++ .../java/com/bugsee/unity/KeepRulesTests.java | 72 ++++++++ Tests~/WrapperPolicy/ProguardRulesTests.cs | 163 ++++++++++++++++++ 10 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 Editor/BugseeAndroidProguardRules.cs create mode 100644 Editor/BugseeAndroidProguardRules.cs.meta create mode 100644 Runtime/WrapperPolicy/ProguardRules.cs create mode 100644 Runtime/WrapperPolicy/ProguardRules.cs.meta create mode 100644 Tests~/Android/wrapper/src/test/java/com/bugsee/unity/KeepRulesTests.java create mode 100644 Tests~/WrapperPolicy/ProguardRulesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd7833..1041b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Android builds with Minify (R8) enabled failed to launch the SDK with `ClassNotFoundException: com.bugsee.unity.UnityWrapper`: Unity does not read the package's `Plugins/Android/bugsee-unity-wrapper.proguard`, so R8 stripped the wrapper classes C# reaches over JNI. A new post-generate step (`BugseeAndroidProguardRules`) appends the rules, between markers and idempotently, to the generated `unityLibrary/proguard-unity.txt` (the library's consumer rules), so no Player Settings change is needed. The rules now also keep the `UnityManagedException` constructor (built by name from C#) and the SDK interfaces implemented with `AndroidJavaProxy`; tests fail when a class C# names over JNI, a wrapper method it calls, or a proxied interface has no rule. - Android builds on Unity 6 failed in Gradle (`DependencyHandler.module(java.lang.Object)` while evaluating `:launcher`): the package wrote its own `Assets/Plugins/Android/baseProjectTemplate.gradle` and `launcherTemplate.gradle` pinning Android Gradle plugin 7.4.2, which cannot run on Unity 6's Gradle 9. The package no longer creates Gradle templates. It patches the Gradle project Unity generates instead (so nothing in Assets goes stale on Unity upgrades): the Bugsee Gradle plugin is added when the generated project pins AGP 8.6+ (previously: any Unity 6), and on 2021.3 the release build type gets `debugSymbolLevel 'FULL'`. The exact templates earlier package versions wrote are removed on an Android build when Unity's default AGP is another major; any other template pinning a different AGP major is left alone with a warning naming the file and the fix. The sample no longer ships custom Gradle templates. Verified with real IL2CPP arm64 builds on 2021.3.45f1 and 6000.0.84f1; Bugsee Gradle plugin 4.0.8 works with Unity 6's AGP 9.0.0 / Gradle 9.3.1. - Android native symbols on Unity 6: release builds no longer force `debugSymbolLevel 'FULL'` in the launcher; the level follows Player Settings (Create symbols.zip), which Unity 6 defaults to Disabled. Public gives symbol tables only (no file:line); Debugging is about ten times larger. An Android build with a Bugsee app token set now warns when the level is not Debugging, naming what is lost and the size trade-off. On Unity 6 the setting is `UnityEditor.Android.UserBuildSettings.DebugSymbols` (level None/SymbolTable/Full plus a Zip format; the 2021.3 `androidCreateSymbols` is obsolete there), and the opt-in also enables the Zip format. Opt in with `Bugsee > Android > Use Full Native Symbols (Debugging) For Symbol Upload` (or `BUGSEE_ANDROID_FULL_SYMBOLS=1` in CI): that build uses Debugging and the setting is restored afterwards (the saved state lives in `Library/Bugsee`, survives an editor exit after an interrupted build, and is restored only while the settings still hold what the opt-in set; a non-Android build never touches it). - Android Gradle cleanup never deletes a template you edited: a file is removed only when it is exactly what an earlier package version generated (or an unedited Unity 2021.3 default base copy), after copying it (and its `.meta`) to `Library/Bugsee/legacy-templates//`. A marker-bearing launcher with your own changes is kept with a warning. A launcher that is left alone and still uses `lintOptions`/`aaptOptions` is reported, and an unreadable AGP version (version catalog, Kotlin DSL) now warns that the Bugsee Gradle plugin was skipped. diff --git a/Documentation~/index.md b/Documentation~/index.md index 9099939..314939d 100644 --- a/Documentation~/index.md +++ b/Documentation~/index.md @@ -153,6 +153,7 @@ Failure policy: **never fail the Unity build**. Missing maps/UUIDs/CLI → warn 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; 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 +5. **Minify** (R8) may be enabled for Release and Debug with no extra setup: the package appends its keep rules (`Plugins/Android/bugsee-unity-wrapper.proguard`) to the generated `unityLibrary/proguard-unity.txt` on every Android build, because Unity does not read that file from a package. The rules keep the wrapper classes C# looks up by name over JNI (`UnityWrapper`, `UnityManagedException`) and the SDK interfaces implemented from C#. They sit between `# bugsee:proguard begin` / `end` markers and are replaced, not duplicated, on the next build. If you add your own JNI code, keep its classes in your Custom Proguard File ### Unity 2021.3 and 2022.3 (no Bugsee Gradle plugin) @@ -190,7 +191,7 @@ Verified with real IL2CPP arm64 builds (staging token set, `bugsee-cli` 0.8.1, u 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). +Releases built without R8 (Minify off, the Unity default) have no mapping and no Java names to restore. Minify may be turned on with no extra setup: the package appends its keep rules to the generated `unityLibrary/proguard-unity.txt` on every build (see the Player Settings checklist above), so `UnityWrapper` and `UnityManagedException` survive R8 and the SDK launches. **iOS** diff --git a/Editor/BugseeAndroidProguardRules.cs b/Editor/BugseeAndroidProguardRules.cs new file mode 100644 index 0000000..e3904cc --- /dev/null +++ b/Editor/BugseeAndroidProguardRules.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using Bugsee.WrapperPolicy; +using UnityEditor.Android; +using UnityEngine; + +namespace Bugsee.Editor +{ + /// + /// Makes R8 keep the wrapper classes C# reaches over JNI. + /// + /// Unity does not read a loose proguard file from a package's Plugins/Android (the Custom Proguard File in + /// Player Settings is user-level), so with Minify enabled R8 stripped UnityWrapper and + /// UnityManagedException and the SDK failed with ClassNotFoundException. Every Unity version generates + /// unityLibrary/proguard-unity.txt and lists it as the library's consumerProguardFiles, so the rules + /// (Plugins/Android/bugsee-unity-wrapper.proguard) are appended there: no project setup, and it applies + /// to the app's R8 run. + /// + /// Like this is not behind a UNITY_ANDROID guard: the callback only + /// fires for Android builds. + /// + sealed class BugseeAndroidProguardRules : IPostGenerateGradleAndroidProject + { + public int callbackOrder => 21; + + public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) + { + try + { + var template = ResolveRulesPath(); + if (template == null) + { + Debug.LogWarning( + "[Bugsee] Could not find Plugins/Android/bugsee-unity-wrapper.proguard in com.bugsee.unity; " + + "with Minify enabled R8 will strip the wrapper classes and the SDK will not launch."); + return; + } + + WarnIfNotConsumerRules(unityLibraryPath); + var target = Path.Combine(unityLibraryPath, "proguard-unity.txt"); + var existing = File.Exists(target) ? File.ReadAllText(target) : ""; + var updated = ProguardRules.Apply(existing, File.ReadAllText(template)); + if (updated != existing) + { + File.WriteAllText(target, updated); + Debug.Log("[Bugsee] Added the wrapper keep rules to " + target); + } + } + catch (Exception ex) + { + // Never fail the build, but be loud: a missing rule is a launch failure in minified builds. + Debug.LogWarning("[Bugsee] Failed to add the wrapper keep rules: " + ex); + } + } + + // A custom main Gradle template can drop consumerProguardFiles, which silently undoes this step. + static void WarnIfNotConsumerRules(string unityLibraryPath) + { + foreach (var name in new[] { "build.gradle", "build.gradle.kts" }) + { + var script = Path.Combine(unityLibraryPath, name); + if (!File.Exists(script)) continue; + if (ProguardRules.DeclaresConsumerRules(File.ReadAllText(script))) return; + Debug.LogWarning( + "[Bugsee] " + script + " does not declare consumerProguardFiles 'proguard-unity.txt'; the wrapper keep rules " + + "will not reach R8 and a Minify build will fail to launch the SDK. Add it to the library's defaultConfig."); + return; + } + } + + static string ResolveRulesPath() + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(BugseeAndroidProguardRules).Assembly); + if (info == null) return null; + var path = Path.Combine(info.resolvedPath, "Plugins", "Android", "bugsee-unity-wrapper.proguard"); + return File.Exists(path) ? path : null; + } + } +} diff --git a/Editor/BugseeAndroidProguardRules.cs.meta b/Editor/BugseeAndroidProguardRules.cs.meta new file mode 100644 index 0000000..25008b3 --- /dev/null +++ b/Editor/BugseeAndroidProguardRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d2e6f8b0c3a4e79b1f203c4d5e6f708 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/BugseeAndroidWrapperManifest.cs b/Editor/BugseeAndroidWrapperManifest.cs index a0a0fe8..72d6327 100644 --- a/Editor/BugseeAndroidWrapperManifest.cs +++ b/Editor/BugseeAndroidWrapperManifest.cs @@ -37,7 +37,7 @@ public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) var template = ResolveTemplatePath(); if (template == null) { - Debug.LogError( + Debug.LogWarning( "[Bugsee] Could not find Plugins/Android/AndroidManifest.xml in com.bugsee.unity; " + "the wrapper provider was not registered, so crashes recovered on relaunch will lack wrapper data."); return; @@ -46,7 +46,7 @@ public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) var target = Path.Combine(unityLibraryPath, "src", "main", "AndroidManifest.xml"); if (!File.Exists(target)) { - Debug.LogError("[Bugsee] unityLibrary manifest not found at " + target + "; wrapper provider not registered."); + Debug.LogWarning("[Bugsee] unityLibrary manifest not found at " + target + "; wrapper provider not registered."); return; } @@ -57,7 +57,7 @@ public void OnPostGenerateGradleAndroidProject(string unityLibraryPath) catch (Exception ex) { // Never fail the build, but be loud: a missing provider is a silent data-loss bug. - Debug.LogError("[Bugsee] Failed to register the wrapper provider: " + ex); + Debug.LogWarning("[Bugsee] Failed to register the wrapper provider: " + ex); } } diff --git a/Plugins/Android/bugsee-unity-wrapper.proguard b/Plugins/Android/bugsee-unity-wrapper.proguard index 474cdaf..e830245 100644 --- a/Plugins/Android/bugsee-unity-wrapper.proguard +++ b/Plugins/Android/bugsee-unity-wrapper.proguard @@ -1,4 +1,6 @@ -# C# calls com.bugsee.unity.UnityWrapper.refineContext via JNI; R8 cannot trace it. +# Applied to the app's R8 run by Editor/BugseeAndroidProguardRules (appended to unityLibrary/proguard-unity.txt); +# Unity does not read this file from Plugins/Android itself. UnityWrapper and UnityManagedException are looked up by +# name from C# over JNI (AndroidJavaClass / AndroidJavaObject), which R8 cannot trace. -keep class com.bugsee.unity.UnityWrapper { static void refineContext(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String); static void channelLog(java.lang.String, int, int); @@ -7,6 +9,10 @@ static void setSecureBuffer(int, int[]); static void attachLifecycleListener(com.bugsee.library.contracts.lifecycle.LifecycleEventListener); } +# Every method C# calls is public static; keeping them all means a new one cannot be stripped by forgetting to list it. +-keep class com.bugsee.unity.UnityWrapper { + public static ; +} -keep class com.bugsee.unity.UnityWrapperProvider { *; } # C# calls UnityWrapper.testCrash() via JNI. Its anonymous Runnable (UnityWrapper$1) is what appears in the # crash's stack frame, so keep its name readable instead of relying on the mapping file. @@ -14,3 +20,18 @@ static void testCrash(); } -keepnames class com.bugsee.unity.UnityWrapper$* +# C# builds this Throwable by name (new AndroidJavaObject("com.bugsee.unity.UnityManagedException", json)); the worker +# routes managed exceptions on its class name, so it must keep both the constructor and its name. +-keep class com.bugsee.unity.UnityManagedException { + (java.lang.String); +} +# Not reached by name (its constants are inlined at compile time); kept so the identity file survives intact. +-keep class com.bugsee.unity.UnityWrapperIdentity { *; } +# C# implements these SDK interfaces with AndroidJavaProxy, which dispatches by method name. The SDK's own consumer +# rules keep them today; stating it here keeps the wrapper working if those rules narrow. +-keep interface com.bugsee.library.contracts.exchange.EventFilter { *; } +-keep interface com.bugsee.library.contracts.reporting.ReportHandler { *; } +-keep interface com.bugsee.library.contracts.reporting.ReportCreationListener { *; } +-keep interface com.bugsee.library.contracts.lifecycle.LifecycleEventListener { *; } +-keep interface com.bugsee.library.contracts.common.Callback1 { *; } +-keep interface com.bugsee.library.contracts.feedback.FeedbackListener { *; } diff --git a/Runtime/WrapperPolicy/ProguardRules.cs b/Runtime/WrapperPolicy/ProguardRules.cs new file mode 100644 index 0000000..47ac959 --- /dev/null +++ b/Runtime/WrapperPolicy/ProguardRules.cs @@ -0,0 +1,53 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace Bugsee.WrapperPolicy +{ + /// + /// Puts the wrapper's keep rules into the generated project's consumer rules file, between markers, so that + /// applying them twice (or after the rules changed) leaves exactly one current copy. + /// + public static class ProguardRules + { + public const string BeginMarker = "# bugsee:proguard begin (managed by com.bugsee.unity; do not edit)"; + public const string EndMarker = "# bugsee:proguard end"; + + /// True when a library module's build script lists proguard-unity.txt as consumer rules (Groovy or Kotlin DSL). + public static bool DeclaresConsumerRules(string gradleScript) => + gradleScript != null && Regex.IsMatch(gradleScript, @"consumerProguardFiles[^\n]*proguard-unity\.txt"); + + public static string Apply(string existing, string rules) + { + if (existing == null) throw new ArgumentNullException(nameof(existing)); + if (rules == null) throw new ArgumentNullException(nameof(rules)); + + string newline = existing.Contains("\r\n") ? "\r\n" : "\n"; + string block = BeginMarker + newline + + rules.Replace("\r\n", "\n").Trim('\n').Replace("\n", newline) + newline + + EndMarker + newline; + + int begin = existing.IndexOf(BeginMarker, StringComparison.Ordinal); + if (begin >= 0) + { + int end = existing.IndexOf(EndMarker, begin, StringComparison.Ordinal); + if (end >= 0) + { + int after = end + EndMarker.Length; + if (existing.Substring(after).StartsWith(newline, StringComparison.Ordinal)) after += newline.Length; + return existing.Substring(0, begin) + block + existing.Substring(after); + } + + // A begin marker whose end marker is gone (a hand edit): what follows it may be someone else's + // rules, so only the dangling marker line is dropped and a complete block is appended. + int lineEnd = existing.IndexOf('\n', begin); + existing = existing.Substring(0, begin) + (lineEnd < 0 ? "" : existing.Substring(lineEnd + 1)); + } + + var sb = new StringBuilder(existing); + if (existing.Length > 0 && !existing.EndsWith("\n", StringComparison.Ordinal)) sb.Append(newline); + sb.Append(block); + return sb.ToString(); + } + } +} diff --git a/Runtime/WrapperPolicy/ProguardRules.cs.meta b/Runtime/WrapperPolicy/ProguardRules.cs.meta new file mode 100644 index 0000000..89caade --- /dev/null +++ b/Runtime/WrapperPolicy/ProguardRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3c1d5e7a9b2f4d68a0e1f2b3c4d5e6f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/KeepRulesTests.java b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/KeepRulesTests.java new file mode 100644 index 0000000..508b291 --- /dev/null +++ b/Tests~/Android/wrapper/src/test/java/com/bugsee/unity/KeepRulesTests.java @@ -0,0 +1,72 @@ +package com.bugsee.unity; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.Test; + +/** Every member the keep rules name must exist, or the rule silently protects nothing. */ +public class KeepRulesTests { + static Class typeOf(String name) throws Exception { + switch (name) { + case "int": return int.class; + case "void": return void.class; + case "int[]": return int[].class; + default: return Class.forName(name); + } + } + + static String rules() throws Exception { + return new String( + Files.readAllBytes(AndroidWrapperTests.repoFile("Plugins/Android/bugsee-unity-wrapper.proguard")), + StandardCharsets.UTF_8); + } + + @Test + public void everyStaticMethodTheRulesKeepExistsOnUnityWrapper() throws Exception { + Matcher block = Pattern.compile("-keep class com\\.bugsee\\.unity\\.UnityWrapper \\{([^}]*)\\}").matcher(rules()); + int checked = 0; + while (block.find()) { + Matcher member = Pattern.compile("static\\s+(\\S+)\\s+(\\w+)\\(([^)]*)\\);").matcher(block.group(1)); + while (member.find()) { + List> params = new ArrayList>(); + for (String p : member.group(3).split(",")) { + if (!p.trim().isEmpty()) params.add(typeOf(p.trim())); + } + Method m = UnityWrapper.class.getDeclaredMethod(member.group(2), params.toArray(new Class[0])); + assertNotNull(m); + assertFalse(member.group(2) + " is not static", !Modifier.isStatic(m.getModifiers())); + assertFalse(member.group(2) + " returns " + m.getReturnType() + ", rule says " + member.group(1), + !m.getReturnType().equals(typeOf(member.group(1)))); + checked++; + } + } + assertFalse("no static members found in the rules", checked == 0); + } + + @Test + public void unityWrapperIsCoveredByTheWildcardRuleAndKeepsInternalsNonPublic() throws Exception { + assertFalse(!Pattern.compile("-keep class com\\.bugsee\\.unity\\.UnityWrapper \\{\\s*public static ;\\s*\\}") + .matcher(rules()).find()); + // C# only calls public static methods; install() and the test hooks are package-private on purpose. + for (Method m : UnityWrapper.class.getDeclaredMethods()) { + if (m.getName().equals("install") || m.getName().endsWith("ForTest")) { + assertFalse(m.getName() + " must stay non-public", Modifier.isPublic(m.getModifiers())); + } + } + } + + @Test + public void managedExceptionHasTheConstructorTheRulesKeep() throws Exception { + assertFalse(!rules().contains("(java.lang.String);")); + assertNotNull(UnityManagedException.class.getConstructor(String.class)); + } +} diff --git a/Tests~/WrapperPolicy/ProguardRulesTests.cs b/Tests~/WrapperPolicy/ProguardRulesTests.cs new file mode 100644 index 0000000..291fb68 --- /dev/null +++ b/Tests~/WrapperPolicy/ProguardRulesTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace Bugsee.WrapperPolicy.Tests +{ + public class ProguardRulesTests + { + const string Unity = "-keep class com.unity3d.player.* { *; }\n-ignorewarnings\n"; + const string Rules = "-keep class a.B { *; }\n-keep class c.D { *; }\n"; + + static string FindRepoRoot() + { + var dir = TestContext.CurrentContext.TestDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) return dir; + dir = Directory.GetParent(dir)?.FullName; + } + throw new InvalidOperationException("Could not locate repo root (package.json)."); + } + + static string RepoFile(string relative) => Path.Combine(FindRepoRoot(), relative); + + static string RulesFile() => File.ReadAllText(RepoFile("Plugins/Android/bugsee-unity-wrapper.proguard")); + + [Test] + public void Apply_appends_one_marked_block_and_keeps_unity_rules() + { + string result = ProguardRules.Apply(Unity, Rules); + Assert.That(result, Does.StartWith(Unity)); + Assert.That(result, Does.Contain(Rules)); + Assert.That(Regex.Matches(result, Regex.Escape(ProguardRules.BeginMarker)).Count, Is.EqualTo(1)); + } + + [Test] + public void Apply_is_idempotent() + { + string once = ProguardRules.Apply(Unity, Rules); + Assert.That(ProguardRules.Apply(once, Rules), Is.EqualTo(once)); + } + + [Test] + public void Apply_replaces_the_block_when_the_rules_changed() + { + string first = ProguardRules.Apply(Unity + "# tail\n", "-keep class old.X { *; }\n"); + string second = ProguardRules.Apply(first, Rules); + Assert.That(second, Does.Not.Contain("old.X")); + Assert.That(second, Does.Contain("a.B")); + Assert.That(second, Does.StartWith(Unity)); + Assert.That(second, Is.EqualTo(Unity + "# tail\n" + ProguardRules.BeginMarker + "\n" + Rules + ProguardRules.EndMarker + "\n")); + Assert.That(Regex.Matches(second, Regex.Escape(ProguardRules.BeginMarker)).Count, Is.EqualTo(1)); + } + + [Test] + public void Apply_keeps_the_rules_after_a_block_whose_end_marker_is_missing() + { + string broken = Unity + ProguardRules.BeginMarker + "\n-keep class old.X { *; }\n-dontwarn user.Rule\n"; + string result = ProguardRules.Apply(broken, Rules); + Assert.That(result, Does.Contain("-dontwarn user.Rule")); + Assert.That(result, Does.Contain("-keep class old.X { *; }")); + Assert.That(result, Does.Contain(Rules + ProguardRules.EndMarker)); + Assert.That(Regex.Matches(result, Regex.Escape(ProguardRules.BeginMarker)).Count, Is.EqualTo(1)); + Assert.That(ProguardRules.Apply(result, Rules), Is.EqualTo(result)); + } + + [Test] + public void DeclaresConsumerRules_reads_groovy_and_kotlin_and_rejects_absence() + { + Assert.That(ProguardRules.DeclaresConsumerRules("defaultConfig {\n consumerProguardFiles 'proguard-unity.txt'\n}"), Is.True); + Assert.That(ProguardRules.DeclaresConsumerRules("consumerProguardFiles(\"proguard-unity.txt\")"), Is.True); + Assert.That(ProguardRules.DeclaresConsumerRules("defaultConfig {\n minSdkVersion 22\n}"), Is.False); + Assert.That(ProguardRules.DeclaresConsumerRules("// consumerProguardFiles other.txt\nproguard-unity.txt"), Is.False); + Assert.That(ProguardRules.DeclaresConsumerRules(null), Is.False); + } + + [Test] + public void Apply_handles_an_empty_file_and_a_missing_final_newline() + { + Assert.That(ProguardRules.Apply("", Rules), Does.StartWith(ProguardRules.BeginMarker)); + Assert.That(ProguardRules.Apply("-dontwarn x", Rules), Does.StartWith("-dontwarn x\n" + ProguardRules.BeginMarker)); + } + + [Test] + public void Apply_keeps_crlf_files_crlf() + { + string result = ProguardRules.Apply("-dontwarn x\r\n", Rules); + Assert.That(Regex.IsMatch(result, "(? + Regex.Replace(csharp, @"(^|\s)//[^\n]*", "$1"); + + // UnityWrapper and UnityManagedException are looked up by name over JNI (dotted or slashed): -keepnames would + // still let R8 remove them, so a full -keep is required. + [Test] + public void Every_unity_wrapper_class_named_in_csharp_has_a_keep_rule() + { + var named = new HashSet(); + foreach (string file in Directory.GetFiles(RepoFile("Runtime"), "*.cs", SearchOption.AllDirectories)) + foreach (Match m in Regex.Matches(CodeOnly(File.ReadAllText(file)), "\"(com[./]bugsee[./]unity[./][A-Za-z0-9_$]+)\"")) + named.Add(m.Groups[1].Value.Replace('/', '.')); + + Assert.That(named, Is.Not.Empty); + string rules = RulesFile(); + foreach (string cls in named) + Assert.That(Regex.IsMatch(rules, @"^-keep\s+class\s+" + Regex.Escape(cls) + @"\s*\{", RegexOptions.Multiline), + Is.True, cls + " is looked up over JNI but R8 is not told to keep it (-keepnames does not stop removal)"); + } + + static IEnumerable RuntimeSources() => + Directory.GetFiles(RepoFile("Runtime"), "*.cs", SearchOption.AllDirectories).Select(f => CodeOnly(File.ReadAllText(f))); + + // Any C# call on a local bound to the UnityWrapper class, in any Runtime file, must hit a public static + // method (the rules keep exactly those: R8 removes unlisted static methods that no Java code calls). + [Test] + public void Every_method_called_on_the_wrapper_over_jni_is_public_static_and_kept() + { + string rules = RulesFile(); + Assert.That(Regex.IsMatch(rules, @"-keep\s+class\s+com\.bugsee\.unity\.UnityWrapper\s*\{\s*public\s+static\s+;\s*\}"), Is.True); + + string java = File.ReadAllText(RepoFile("Plugins/Android/UnityWrapperProvider.java")); + var called = new HashSet(); + foreach (string source in RuntimeSources()) + foreach (Match v in Regex.Matches(source, @"(\w+)\s*=\s*new\s+AndroidJavaClass\(\s*""com\.bugsee\.unity\.UnityWrapper""\s*\)")) + foreach (Match c in Regex.Matches(source, @"\b" + v.Groups[1].Value + @"\.(?:CallStatic|Call)(?:<[^>]+>)?\(\s*""(\w+)""")) + called.Add(c.Groups[1].Value); + + Assert.That(called, Is.Not.Empty); + foreach (string method in called) + Assert.That(Regex.IsMatch(java, @"^\s*public\s+static\s+\S+\s+" + method + @"\(", RegexOptions.Multiline), Is.True, + "UnityWrapper." + method + " is called over JNI but is not a public static method (not covered by the keep rule)"); + } + + // AndroidJavaProxy dispatches by interface method name, so a proxied interface must not be stripped or renamed. + [Test] + public void Every_interface_implemented_by_an_android_java_proxy_is_kept() + { + string proxies = string.Join("\n", RuntimeSources()); + var interfaces = Regex.Matches(proxies, @"base\(\s*""(com\.bugsee\.[A-Za-z0-9_.$]+)""") + .Cast().Select(m => m.Groups[1].Value).Distinct().ToList(); + Assert.That(interfaces, Is.Not.Empty); + string rules = RulesFile(); + foreach (string cls in interfaces) + Assert.That(Regex.IsMatch(rules, @"^-keep\s+interface\s+" + Regex.Escape(cls) + @"\s*\{\s*\*;\s*\}", RegexOptions.Multiline), + Is.True, cls + " is proxied from C# but not kept"); + } + + [Test] + public void Wiring_only_the_editor_step_references_the_rules_file_and_policy() + { + string editor = File.ReadAllText(RepoFile("Editor/BugseeAndroidProguardRules.cs")); + Assert.That(editor, Does.Contain("IPostGenerateGradleAndroidProject")); + Assert.That(editor, Does.Contain("\"proguard-unity.txt\"")); + Assert.That(editor, Does.Contain("ProguardRules.Apply(")); + Assert.That(editor, Does.Contain("\"bugsee-unity-wrapper.proguard\"")); + } + } +}