From 698c632ce87f97733a08d9567d988e0d05f3f2b4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:40:19 +0500 Subject: [PATCH 01/27] 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 a74b63fef13886782b351fc2efe3d0bb21c0485e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Fri, 9 Oct 2026 15:57:43 +0500 Subject: [PATCH 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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 08/27] 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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 21/27] 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 22/27] 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 23/27] 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 24/27] 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 25/27] 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 26/27] 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 27/27] 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() {