diff --git a/Plugins/iOS/BugseeUnityBridge.mm b/Plugins/iOS/BugseeUnityBridge.mm index 4319e4a..e49a01b 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,157 @@ static void BugseeRunOnMain(dispatch_block_t block) } } +typedef void (*BugseeManagedReportCreateCallback)(int succeeded, uint64_t uploadToken); + +static uint64_t gManagedReportUploadFence; +static NSMutableSet *gCancelledManagedReportUploadIds; + +static BOOL BugseeManagedReportUploadStillActive(uint64_t uploadId, uint64_t uploadFence) +{ + if (uploadId == 0 || uploadFence != gManagedReportUploadFence) { + return NO; + } + if (!gCancelledManagedReportUploadIds) { + return YES; + } + return ![gCancelledManagedReportUploadIds containsObject:@(uploadId)]; +} + +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 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) { + return; + } + + id summary = d[@"summary"]; + if ([summary isKindOfClass:[NSString class]]) { + [report setSummary:summary]; + } + id desc = d[@"description"]; + if ([desc isKindOfClass:[NSString class]]) { + [report setDescription:desc]; + } + id sev = d[@"severity"]; + if ([sev respondsToSelector:@selector(integerValue)]) { + NSInteger sevVal = [sev integerValue]; + if (sevVal != 0) { + [report setSeverity:(BugseeSeverityLevel)sevVal]; + } + } + + id labels = d[@"labels"]; + if ([labels isKindOfClass:[NSArray class]]) { + NSMutableArray *clean = [NSMutableArray array]; + for (id label in (NSArray *)labels) { + if ([label isKindOfClass:[NSString class]]) { + [clean addObject:label]; + } + } + report.labels = clean; + } + + id removals = d[@"attributeRemovals"]; + if ([removals isKindOfClass:[NSArray class]]) { + for (id name in (NSArray *)removals) { + if ([name isKindOfClass:[NSString class]]) { + [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]]) { + if (shouldReplaceAll) { + [report clearAllAttributes]; + } + [(NSDictionary *)attrs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if ([key isKindOfClass:[NSString class]]) { + [report setAttribute:(NSString *)key withValue:obj]; + } + }]; + } else if (shouldReplaceAll) { + [report clearAllAttributes]; + } + + id attachments = d[@"attachments"]; + if ([attachments isKindOfClass:[NSArray class]]) { + [report clearAllAttachments]; + 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"; + id fileNameVal = att[@"fileName"]; + NSString *fileName = [fileNameVal isKindOfClass:[NSString class]] ? fileNameVal : nil; + 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) { + 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) { + continue; + } + BugseeAttachment *attachment = [BugseeAttachment attachmentWithName:name filename:fileName data:data]; + if (attachment) { + BugseeBridgeSetAttachmentMimeTypeIfNeeded(attachment, mimeType); + [report setAttachment:attachment]; + } + } + } +} + extern "C" { void _bugsee_launch(const char *appToken, const char *optionsJson) @@ -355,10 +508,96 @@ void _bugsee_free(char *ptr) } } +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:^{ + runDeleteIfAllowed(); + }]; + } else { + runDeleteIfAllowed(); + } + }); +} + +void _bugsee_cancel_managed_report_upload(uint64_t uploadId) +{ + 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) +{ + if (!reportJson) { + if (callback) { + callback(0, uploadId); + } + return; + } + NSString *jsonCopy = [NSString stringWithUTF8String:reportJson]; + [Bugsee createReportWithCompletion:^(BugseeExtendedReport *_Nullable report) { + if (!BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { + if (callback) { + callback(0, uploadId); + } + return; + } + if (!report) { + if (callback) { + callback(0, uploadId); + } + return; + } + NSDictionary *dict = BugseeDeserializeJson(jsonCopy.UTF8String); + if (![dict isKindOfClass:[NSDictionary class]]) { + if (callback) { + callback(0, uploadId); + } + return; + } + BugseeBridgeApplyExtendedReportDict(report, dict); + if (!BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { + if (callback) { + callback(0, uploadId); + } + return; + } + if (callback) { + callback(1, uploadId); + } + if (BugseeManagedReportUploadStillActive(uploadId, uploadFence)) { + [Bugsee uploadReport:report completion:nil]; + } + }]; +} + } // extern "C" #else // !BUGSEE_IOS_SDK +typedef void (*BugseeManagedReportCreateCallback)(int succeeded); +typedef int (*BugseeDeleteCollectedDataShouldRunFn)(int capturedGeneration); + extern "C" { void _bugsee_launch(const char *appToken, const char *optionsJson) { (void)appToken; (void)optionsJson; } @@ -393,6 +632,25 @@ void _bugsee_feedback_show(void) {} void _bugsee_appearance_set_string(const char *propertyName, const char *propertyValue) { (void)propertyName; (void)propertyValue; } char *_bugsee_appearance_get_string(const char *propertyName) { (void)propertyName; return NULL; } void _bugsee_free(char *ptr) { (void)ptr; } +void _bugsee_delete_collected_data(int capturedGeneration, BugseeDeleteCollectedDataShouldRunFn shouldRun) +{ + (void)capturedGeneration; + (void)shouldRun; +} +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/Plugins/iOS/BugseeUnityCallbacks.mm b/Plugins/iOS/BugseeUnityCallbacks.mm index 5034547..bfadb1e 100644 --- a/Plugins/iOS/BugseeUnityCallbacks.mm +++ b/Plugins/iOS/BugseeUnityCallbacks.mm @@ -251,18 +251,40 @@ 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 removeAttributeForName:(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 replaceAttachments = d[@"attachmentsReplaceAll"]; + BOOL shouldReplaceAttachments = [replaceAttachments respondsToSelector:@selector(boolValue)] && [replaceAttachments boolValue]; + id attachments = d[@"attachments"]; if ([attachments isKindOfClass:[NSArray class]]) { + if (shouldReplaceAttachments) { + [report clearAttachments]; + } for (id item in (NSArray *)attachments) { if (![item isKindOfClass:[NSDictionary class]]) continue; NSDictionary *att = (NSDictionary *)item; @@ -443,6 +465,62 @@ 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, double timestampUnixSeconds) +{ + 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 factory = [Bugsee getExchangeFactory]; + if (!factory) { + return; + } + NSTimeInterval timestamp = timestampUnixSeconds > 0 ? timestampUnixSeconds : [[NSDate date] timeIntervalSince1970]; + id breadcrumb = [factory createBreadcrumbWithTimestamp:timestamp + category:categoryText + level:(BugseeLogLevel)iosLevel + message:messageText + type:@"manual" + data:nil]; + if (!breadcrumb) { + return; + } + [channel addBreadcrumb:breadcrumb]; +} + void _bugsee_ensure_wrapper(const char *version, const char *build) { if (version) gWrapperVersion = [NSString stringWithUTF8String:version]; @@ -629,6 +707,14 @@ 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, 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/Bugsee.cs b/Runtime/Bugsee.cs index 20a67fe..85a380d 100644 --- a/Runtime/Bugsee.cs +++ b/Runtime/Bugsee.cs @@ -85,6 +85,17 @@ 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(); + + /// 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); + public static IBugseeExchangeFactory GetExchangeFactory() => Bridge.GetExchangeFactory(); @@ -119,6 +130,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 +181,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 3438544..d0339cb 100644 --- a/Runtime/Platform/Android/AndroidBridge.cs +++ b/Runtime/Platform/Android/AndroidBridge.cs @@ -30,6 +30,7 @@ sealed class AndroidBridge : IBugseeNativeBridge AndroidFeedback _feedback; AndroidAppearance _appearance; IBugseeExchangeFactory _exchangeFactory; + AndroidManagedReport _openReport; static bool _loggedMissingNetworkFactory; public bool IsSupported => true; @@ -68,6 +69,7 @@ AndroidJavaObject CurrentActivity() public void Launch(string appToken, IDictionary options) { ManagedExceptionPayload.EnsureBuildIdentity(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); EnsureWrapperRegistered(); InstallDefaultListeners(); using (var activity = CurrentActivity()) @@ -81,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); @@ -91,6 +94,9 @@ public void Relaunch(IDictionary options) public void Stop(Action completion = null) { + AndroidManagedReportUploadFence.Invalidate(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; HostLogForwarder.Uninstall(); ExceptionPipeline.Uninstall(); if (completion == null) @@ -452,6 +458,102 @@ public void SetLifecycleEventListener(ILifecycleEventListener listener) InstallDefaultListeners(); } + public void NotifyLifecycle(string eventType) { } + + public void DeleteCollectedDataOnDevice() + { + AndroidManagedReportUploadFence.Invalidate(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + if (GetLaunched()) + { + Stop(() => InvokeDeleteCollectedDataOnDevice(deleteGeneration)); + return; + } + + InvokeDeleteCollectedDataOnDevice(deleteGeneration); + } + + void InvokeDeleteCollectedDataOnDevice(int deleteGeneration) + { + if (!DeleteCollectedDataLaunchGeneration.ShouldRunDelete(deleteGeneration)) + return; + + _bugsee.CallStatic( + "deleteCollectedDataOnDevice", + true, + new BooleanCallback1Proxy(success => + { + if (!success) + Debug.LogError("[Bugsee] deleteCollectedDataOnDevice failed."); + })); + } + + public IReport CreateReport() + { + 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?.ReleaseSnapshotFiles(); + _openReport = null; + } + + public void UploadReport(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)); + if (_openReport == null) + throw new InvalidOperationException("CreateReport failed."); + var snapshot = _openReport; + _openReport = null; + var uploadFence = AndroidManagedReportUploadFence.Current; + _bugsee.CallStatic( + "createReport", + new ReportCreationListenerProxy(snapshot, _bugsee, uploadFence)); + } + + 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 javaLevel = levelClass.CallStatic( + "fromValue", + (sbyte)BreadcrumbLevelMap.ToAndroid(level))) + using (var javaFactory = _bugsee.CallStatic("getExchangeFactory")) + using (var dataMap = new AndroidJavaObject("java.util.HashMap")) + { + if (javaFactory == null) + return; + var breadcrumb = javaFactory.Call( + "createBreadcrumb", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + category ?? "", + message ?? "", + javaLevel, + "manual", + dataMap); + if (breadcrumb == null) + return; + _bugsee.CallStatic("addBreadcrumb", breadcrumb); + } + } + void InstallDefaultListeners() { if (_lifecycleProxy == null) diff --git a/Runtime/Platform/Android/AndroidReport.cs b/Runtime/Platform/Android/AndroidReport.cs index 308d221..d1fb1a9 100644 --- a/Runtime/Platform/Android/AndroidReport.cs +++ b/Runtime/Platform/Android/AndroidReport.cs @@ -208,5 +208,326 @@ public void SetData(string text) SetData(Encoding.UTF8.GetBytes(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 HashSet _removedAttributes = new HashSet(); + readonly List _labels = new List(); + readonly List _attachments = new List(); + bool _emailAssigned; + bool _attributesOverlayDirty; + bool _attributesClearAll; + bool _labelsDirty; + bool _attachmentsDirty; + + public string Id => ""; + + public IssueType Type => IssueType.Bug; + + public string Summary { get; set; } + public string Description { get; set; } + + public string Email + { + get => _email; + set + { + _email = value; + _emailAssigned = true; + } + } + + string _email; + + 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; + _removedAttributes.Remove(name); + _attributesOverlayDirty = true; + } + + public void RemoveAttribute(string name) + { + if (name == null) return; + _attributes.Remove(name); + _removedAttributes.Add(name); + _attributesOverlayDirty = true; + } + + public void ClearAllAttributes() + { + _attributes.Clear(); + _removedAttributes.Clear(); + _attributesClearAll = true; + _attributesOverlayDirty = false; + } + + public IReadOnlyList Labels => _labels; + + public void AddLabel(string label) + { + if (!string.IsNullOrEmpty(label)) + { + _labels.Add(label); + _labelsDirty = true; + } + } + + 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) + { + if (!string.IsNullOrEmpty(label)) _labels.Add(label); + } + } + + public IReadOnlyList Attachments => _attachments; + + public IAttachment AddAttachmentFile(string path, string name, string mimeType) + { + 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.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) + return null; + var att = new AndroidManagedAttachment(string.IsNullOrEmpty(name) ? "attachment" : name, MarkAttachmentsDirty); + att.MimeType = string.IsNullOrEmpty(mimeType) ? "application/octet-stream" : mimeType; + att.SetData(data); + _attachments.Add(att); + _attachmentsDirty = true; + return att; + } + + public void ClearAttachments() + { + ReleaseSnapshotFiles(); + _attachments.Clear(); + _attachmentsDirty = true; + } + + void MarkAttachmentsDirty() => _attachmentsDirty = true; + + internal void ApplyTo(AndroidJavaObject javaReport) + { + if (javaReport == null) + return; + + var report = new AndroidReport(javaReport); + if (Summary != null) + report.Summary = Summary; + if (Description != null) + report.Description = Description; + if (_emailAssigned) + report.Email = Email; + if (Severity.HasValue) + report.Severity = Severity; + + if (_attributesClearAll) + { + report.ClearAllAttributes(); + foreach (var kv in _attributes) + { + if (kv.Key == null) continue; + 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) + { + report.ClearLabels(); + report.SetLabels(_labels); + } + + 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; + string _path; + bool _ownsSnapshotFile; + + public AndroidManagedAttachment(string name, Action markDirty) + { + _markDirty = markDirty; + 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; + _path = null; + _markDirty?.Invoke(); + } + + public void SetData(string text) + { + _text = text ?? ""; + _bytes = null; + _path = null; + _markDirty?.Invoke(); + } + + 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; + if (!string.IsNullOrEmpty(_path)) + attachment = report.AddAttachmentFile(_path, Name ?? "attachment", MimeType); + else + { + byte[] payload = _bytes; + if ((payload == null || payload.Length == 0) && _text != null) + payload = Encoding.UTF8.GetBytes(_text); + if (payload != null && payload.Length > 0) + attachment = report.AddAttachmentBytes(payload, Name ?? "attachment", MimeType); + } + + if (attachment == null) + return; + attachment.Filename = Filename; + attachment.MimeType = MimeType; + } + } } #endif diff --git a/Runtime/Platform/Android/Proxies/CallbackProxies.cs b/Runtime/Platform/Android/Proxies/CallbackProxies.cs index 1a8a3a5..63eeda9 100644 --- a/Runtime/Platform/Android/Proxies/CallbackProxies.cs +++ b/Runtime/Platform/Android/Proxies/CallbackProxies.cs @@ -160,5 +160,75 @@ public RunnableProxy(Action action) : base("java.lang.Runnable") public void run() => _action?.Invoke(); } + + sealed class BooleanCallback1Proxy : AndroidJavaProxy + { + readonly Action _onResult; + + public BooleanCallback1Proxy(Action onResult) + : base("com.bugsee.library.contracts.common.Callback1") + { + _onResult = onResult; + } + + public void run(AndroidJavaObject value) + { + if (value == null) + { + _onResult?.Invoke(false); + return; + } + + _onResult?.Invoke(value.Call("booleanValue")); + } + } + + sealed class ReportCreationListenerProxy : AndroidJavaProxy + { + readonly AndroidManagedReport _snapshot; + readonly AndroidJavaClass _bugsee; + readonly ulong _uploadFence; + + 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) + { + try + { + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; + + if (report == null) + { + Debug.LogError("[Bugsee] CreateReport failed."); + return; + } + + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; + _snapshot.ApplyTo(report); + if (!AndroidManagedReportUploadFence.IsActive(_uploadFence)) + return; + _bugsee.CallStatic("upload", report); + } + catch (Exception ex) + { + Debug.LogException(ex); + } + finally + { + _snapshot?.ReleaseSnapshotFiles(); + } + } + } } #endif diff --git a/Runtime/Platform/Editor/EditorBridge.cs b/Runtime/Platform/Editor/EditorBridge.cs index a682870..12eb5f3 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,27 @@ 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 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 69e7f30..59fc261 100644 --- a/Runtime/Platform/IBugseeNativeBridge.cs +++ b/Runtime/Platform/IBugseeNativeBridge.cs @@ -70,6 +70,15 @@ public interface IBugseeNativeBridge void SetReportHandler(IReportHandler handler); void SetLifecycleEventListener(ILifecycleEventListener listener); + void DeleteCollectedDataOnDevice(); + IReport CreateReport(); + void DiscardReport(IReport report); + 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 6e7a82e..499778f 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; @@ -9,9 +10,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 { @@ -23,12 +24,32 @@ namespace Bugsee.Platform.IOS /// sealed class IOSBridge : IBugseeNativeBridge { + delegate void ManagedReportUploadNativeCallback(int succeeded, ulong uploadToken); + delegate int DeleteCollectedDataShouldRunNativeCallback(int capturedGeneration); + + ulong _inFlightUploadToken; + ulong _managedReportUploadFence; + int _reportUploadGeneration; + bool _launched; bool _blackout; IosAppearance _appearance; + IosReport _openReport; + IosLiveReport _openReportHandle; + readonly object _uploadSnapshotReportsGate = new object(); + readonly Dictionary _uploadSnapshotReports = new Dictionary(); + 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(); public void EnsureWrapperRegistered() => IosNativeCallbacks.EnsureWrapper(); @@ -39,6 +60,11 @@ public void Launch(string appToken, IDictionary options) if (!MainThreadDispatcher.RunSyncLifecycle(() => { EnsureWrapperRegistered(); + DeleteCollectedDataLaunchGeneration.BumpForLaunch(); + _networkLaunchBuffer.BeginNewLaunchCycle(); + _breadcrumbLaunchBuffer.BeginNewLaunchCycle(); + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.BeforeLaunched); _bugsee_launch(appToken, ToJsonObject(OptionPlatformGate.ForIos(options))); _launched = true; ExceptionPipeline.Install(this, options); @@ -53,6 +79,11 @@ 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))); ExceptionPipeline.Install(this, options); HostLogForwarder.InstallOnce(this); @@ -71,6 +102,12 @@ public void Stop(Action completion = null) _bugsee_clear_wrapper_channel(); _bugsee_stop(); _launched = false; + CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); completion?.Invoke(); })) { @@ -105,7 +142,181 @@ 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.SetPhaseFromLifecycle(NetworkLaunchPhase.Launched); + _breadcrumbLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Launched); + } + else if (eventType == LifecycleEvents.Stopped) + { + _networkLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhaseFromLifecycle(NetworkLaunchPhase.Stopped); + } + } + + public void DeleteCollectedDataOnDevice() + { + var deleteGeneration = DeleteCollectedDataLaunchGeneration.CaptureForPendingDelete(); + if (GetLaunched()) + { + if (!MainThreadDispatcher.RunSyncLifecycle(() => + { + HostLogForwarder.Uninstall(); + ExceptionPipeline.Uninstall(); + _bugsee_clear_wrapper_channel(); + CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _launched = false; + _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); + })) + { + Debug.LogError("[Bugsee] DeleteCollectedDataOnDevice timed out waiting for the Unity main thread."); + } + + return; + } + + CancelManagedReportUpload(); + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + _openReportHandle = null; + _networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped); + _bugsee_delete_collected_data(deleteGeneration, ShouldRunDeleteCollectedDataNative); + } + + [MonoPInvokeCallback(typeof(DeleteCollectedDataShouldRunNativeCallback))] + static int ShouldRunDeleteCollectedDataNative(int capturedGeneration) => + DeleteCollectedDataLaunchGeneration.ShouldRunDelete(capturedGeneration) ? 1 : 0; + + void CancelManagedReportUpload() + { + _reportUploadGeneration++; + _managedReportUploadFence++; + _inFlightUploadToken = 0; + ReleaseAllUploadSnapshotReports(); + _bugsee_invalidate_managed_report_uploads(); + } + + void ReleaseAllUploadSnapshotReports() + { + lock (_uploadSnapshotReportsGate) + { + foreach (var kv in _uploadSnapshotReports) + kv.Value?.ReleaseSnapshotFiles(); + _uploadSnapshotReports.Clear(); + } + } + + void ReleaseUploadSnapshotReport(ulong uploadToken) + { + IosReport report; + lock (_uploadSnapshotReportsGate) + { + if (!_uploadSnapshotReports.TryGetValue(uploadToken, out report)) + return; + _uploadSnapshotReports.Remove(uploadToken); + } + + report?.ReleaseSnapshotFiles(); + } + + public IReport CreateReport() + { + if (_openReport != null) + throw new InvalidOperationException("a report is already open"); + _openReport = new IosReport(new IosReportDto()); + _openReportHandle = new IosLiveReport(_openReport); + return _openReportHandle; + } + + 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)); + _reportUploadGeneration++; + _openReport?.ReleaseSnapshotFiles(); + _openReport = null; + _openReportHandle = null; + } + + public void UploadReport(IReport report) + { + if (!ReferenceEquals(report, _openReportHandle)) + 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 snapshotReport = _openReport; + var json = snapshotReport.ToResultJson(); + var uploadToken = (ulong)++_reportUploadGeneration; + var uploadFence = _managedReportUploadFence; + lock (_uploadSnapshotReportsGate) + _uploadSnapshotReports[uploadToken] = snapshotReport; + _openReport = null; + _openReportHandle = null; + _uploadCompletionBridge = this; + _inFlightUploadToken = uploadToken; + _bugsee_upload_managed_report(json, uploadToken, uploadFence, OnManagedReportCreateCompletion); + } + + [MonoPInvokeCallback(typeof(ManagedReportUploadNativeCallback))] + static void OnManagedReportCreateCompletion(int succeeded, ulong uploadToken) + { + // Instance is resolved through the static bridge reference set at upload time. + if (_uploadCompletionBridge == null) + return; + _uploadCompletionBridge.HandleManagedReportCreateCompletion(succeeded != 0, uploadToken); + } + + static IOSBridge _uploadCompletionBridge; + + void HandleManagedReportCreateCompletion(bool succeeded, ulong uploadToken) + { + ReleaseUploadSnapshotReport(uploadToken); + if (uploadToken != _inFlightUploadToken) + return; + _inFlightUploadToken = 0; + if (!succeeded) + 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); + _breadcrumbLaunchBuffer.Enqueue(new IosPendingBreadcrumb + { + 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, + breadcrumb.TimestampUnixSeconds); } public IBugseeExchangeFactory GetExchangeFactory() => IosExchangeFactory.Instance; @@ -223,6 +434,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; @@ -307,6 +559,22 @@ 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, + double timestampUnixSeconds); + [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( + 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); [DllImport("__Internal")] static extern void _bugsee_logException(string name, string reason, bool handled); @@ -384,6 +652,125 @@ public string GetString(string propertyName) public IReadOnlyDictionary ToMap() => new Dictionary(_cache); } + + /// Managed report snapshot from ; native work runs at upload. + internal sealed class IosLiveReport : IReport + { + readonly IosReport _inner; + + internal IosLiveReport(IosReport inner) + { + _inner = inner; + } + + public string Id => _inner.Id; + public IssueType Type => _inner.Type; + + public string Summary + { + get => _inner.Summary; + set => _inner.Summary = value; + } + + public string Description + { + get => _inner.Description; + set => _inner.Description = value; + } + + public string Email + { + get => _inner.Email; + set => _inner.Email = value; + } + + public IssueSeverity? Severity + { + get => _inner.Severity; + 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); + + public void RemoveAttribute(string name) => _inner.RemoveAttribute(name); + + public void ClearAllAttributes() => _inner.ClearAllAttributes(); + + public IReadOnlyList Labels => _inner.Labels; + + public void AddLabel(string label) => _inner.AddLabel(label); + + public void ClearLabels() => _inner.ClearLabels(); + + public void SetLabels(IEnumerable labels) => _inner.SetLabels(labels); + + 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)); + else + wrapped.Add(inner[i]); + } + return wrapped; + } + } + + public IAttachment AddAttachmentFile(string path, string name, string mimeType) + { + var att = _inner.AddAttachmentFile(path, name, mimeType) as IosAttachment; + return att == null ? null : new IosLiveAttachment(att); + } + + public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) + { + var att = _inner.AddAttachmentBytes(data, name, mimeType) as IosAttachment; + return att == null ? null : new IosLiveAttachment(att); + } + + public void ClearAttachments() => _inner.ClearAttachments(); + } + + sealed class IosLiveAttachment : IAttachment + { + readonly IosAttachment _inner; + + internal IosLiveAttachment(IosAttachment inner) + { + _inner = inner; + } + + public string Name + { + get => _inner.Name; + set => _inner.Name = value; + } + + public string Filename + { + get => _inner.Filename; + set => _inner.Filename = value; + } + + public string MimeType + { + get => _inner.MimeType; + set => _inner.MimeType = value; + } + + public void SetData(byte[] data) => _inner.SetData(data); + + public void SetData(string text) => _inner.SetData(text); + } } sealed class IosExchangeFactory : IBugseeExchangeFactory diff --git a/Runtime/Platform/IOS/IosNativeCallbacks.cs b/Runtime/Platform/IOS/IosNativeCallbacks.cs index 3ee81de..29fa701 100644 --- a/Runtime/Platform/IOS/IosNativeCallbacks.cs +++ b/Runtime/Platform/IOS/IosNativeCallbacks.cs @@ -311,7 +311,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/Platform/IOS/IosReport.cs b/Runtime/Platform/IOS/IosReport.cs index 1b6b1bf..0eccb8a 100644 --- a/Runtime/Platform/IOS/IosReport.cs +++ b/Runtime/Platform/IOS/IosReport.cs @@ -36,10 +36,15 @@ 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; + bool _attachmentsReplaceAll; public IosReport(IosReportDto dto) { @@ -100,34 +105,47 @@ 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; 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) { @@ -162,6 +180,7 @@ public IAttachment AddAttachmentFile(string path, string name, string mimeType) att.MimeType = mimeType ?? "application/octet-stream"; att.SetSnapshotPath(snapshotPath); _attachments.Add(att); + _attachmentsDirty = true; return att; } @@ -174,6 +193,7 @@ public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType) att.MimeType = mimeType ?? "application/octet-stream"; att.SetAttachmentBytes(data); _attachments.Add(att); + _attachmentsDirty = true; return att; } @@ -194,6 +214,8 @@ public void ClearAttachments() for (var i = 0; i < _attachments.Count; i++) _attachments[i].DeleteSnapshotIfOwned(); _attachments.Clear(); + _attachmentsDirty = true; + _attachmentsReplaceAll = true; } public void ReleaseSnapshotFiles() @@ -208,76 +230,164 @@ 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).Append(','); + AppendString(sb, "email", _dto.email); + if (_dto.severity != 0) + { + sb.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) + 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) + { + sb.Append(",\"attributeRemovals\":["); + var firstRemoval = true; + foreach (var name in _removedAttributes) + { + if (!firstRemoval) sb.Append(','); + firstRemoval = false; + IosJsonStringEncoding.AppendQuoted(sb, name); + } + sb.Append(']'); + } + + if (_attributes.Count > 0 || _attributesClearAll || _hadAttributeArray) { - if (kv.Key == null) continue; - if (!first) sb.Append(','); - first = false; - AppendRawString(sb, kv.Key); - sb.Append(':'); - AppendValue(sb, kv.Value); + 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('}'); } - sb.Append("},"); + + if (_attributesClearAll || _hadAttributeArray) + sb.Append(",\"attributesReplaceAll\":true"); } - 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(']'); + if (_attachmentsReplaceAll) + sb.Append(",\"attachmentsReplaceAll\":true"); } - 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) { 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; } - 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('"'); } } @@ -411,11 +521,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/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/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..0357006 --- /dev/null +++ b/Runtime/WrapperPolicy/IosPendingBreadcrumb.cs @@ -0,0 +1,10 @@ +namespace Bugsee.WrapperPolicy +{ + public struct IosPendingBreadcrumb + { + public string Category; + public string Message; + public int IosLevel; + public double TimestampUnixSeconds; + } +} 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/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs new file mode 100644 index 0000000..d16c5c8 --- /dev/null +++ b/Runtime/WrapperPolicy/NetworkEventLaunchBuffer.cs @@ -0,0 +1,130 @@ +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 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 + { + lock (_gate) + return _phase; + } + } + + public void SetSubmitHandler(Action submit) => _submit = submit; + + public void SetPhase(NetworkLaunchPhase phase) + { + lock (_gate) + { + if (phase == NetworkLaunchPhase.Launched) + { + if (_phase == NetworkLaunchPhase.Launched) + return; + _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; + _phase = NetworkLaunchPhase.Launched; + FlushPendingLocked(); + return; + } + + if (phase == NetworkLaunchPhase.Stopped) + { + if (_lifecycleStoppedPendingAfterNewLaunch) + return; + _phase = NetworkLaunchPhase.Stopped; + _pending.Clear(); + return; + } + + if (phase == NetworkLaunchPhase.BeforeLaunched) + _phase = NetworkLaunchPhase.BeforeLaunched; + } + } + + public void Enqueue(T item) + { + if (item == null) + return; + + lock (_gate) + { + if (_phase == NetworkLaunchPhase.Stopped) + return; + + if (_phase == NetworkLaunchPhase.Launched) + { + var submit = _submit; + if (submit != null) + submit(item); + return; + } + + _pending.Add(item); + } + } + + void FlushPendingLocked() + { + if (_submit == null || _pending.Count == 0) + { + _pending.Clear(); + return; + } + + var copy = new List(_pending); + _pending.Clear(); + var submit = _submit; + 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/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/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/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..aae0638 --- /dev/null +++ b/Tests~/WrapperPolicy/IosReportNonFiniteJsonTests.cs @@ -0,0 +1,84 @@ +using System.Text; +using Bugsee.Contracts.Options; +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")); + } + + [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)); + } + + [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")); + } + + [Test] + public void ToResultJson_appends_attachments_without_replace_all_until_clear() + { + var report = new IosReport(new IosReportDto()); + var att = report.AddAttachmentBytes(Encoding.UTF8.GetBytes("hello"), "log.txt", "text/plain"); + Assert.That(att, Is.Not.Null); + 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/NetworkEventLaunchBufferTests.cs b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs new file mode 100644 index 0000000..fccb2c9 --- /dev/null +++ b/Tests~/WrapperPolicy/NetworkEventLaunchBufferTests.cs @@ -0,0 +1,144 @@ +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 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 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() + { + 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() + { + 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..6bdaa0e --- /dev/null +++ b/Tests~/WrapperPolicy/PublicApiSourceTests.cs @@ -0,0 +1,576 @@ +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")); + Assert.That(facade, Does.Contain("Discard")); + } + + [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:YES")); + } + + [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_upload_managed_report_uses_beta5_create_and_apply_once() + { + string bridge = File.ReadAllText(RepoFile("Plugins/iOS/BugseeUnityBridge.mm")); + 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")); + 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, "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")); + 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() + { + 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] + 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_stops_then_deletes() + { + string android = File.ReadAllText(RepoFile("Runtime/Platform/Android/AndroidBridge.cs")); + string body = ExtractMethodBody(android, "public void DeleteCollectedDataOnDevice()"); + Assert.That(body, Does.Contain("CaptureForPendingDelete()")); + Assert.That(body, Does.Contain("InvokeDeleteCollectedDataOnDevice(deleteGeneration)")); + string invoke = ExtractMethodBody(android, "void InvokeDeleteCollectedDataOnDevice(int deleteGeneration)"); + Assert.That(invoke, Does.Contain("ShouldRunDelete(deleteGeneration)")); + Assert.That(invoke, Does.Contain("deleteCollectedDataOnDevice")); + Assert.That(invoke, Does.Contain("true")); + Assert.That(invoke, Does.Contain("BooleanCallback1Proxy")); + + string 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] + 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 (_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 ?? \"\"")); + Assert.That(apply, Does.Not.Contain("Summary ?? \"\"")); + } + + [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")); + 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, "BugseeBridgeApplyExtendedReportDict"); + Assert.That(body, Does.Contain("sevVal != 0")); + 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, "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] + 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() + { + string body = ExtractMethodBody( + File.ReadAllText(RepoFile("Runtime/Platform/IOS/IosReport.cs")), + "public string ToResultJson()"); + 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_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_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("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] + 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_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("BeginNewLaunchCycle()")); + 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.Contain("TimestampUnixSeconds")); + Assert.That(add, Does.Not.Contain("_bugsee_channel_breadcrumb")); + + string notify = ExtractMethodBody(ios, "public void NotifyLifecycle(string eventType)"); + Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhaseFromLifecycle")); + Assert.That(notify, Does.Contain("_networkLaunchBuffer.SetPhaseFromLifecycle")); + } + + [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")); + Assert.That(notify, Does.Contain("_breadcrumbLaunchBuffer.SetPhaseFromLifecycle")); + Assert.That(notify, Does.Contain("_networkLaunchBuffer.SetPhaseFromLifecycle")); + + string launch = ExtractMethodBody(ios, "public void Launch(string appToken, IDictionary options)"); + Assert.That(launch, Does.Contain("SetPhase(NetworkLaunchPhase.BeforeLaunched)")); + + string stop = ExtractMethodBody(ios, "public void Stop(Action completion = null)"); + Assert.That(stop, Does.Contain("_bugsee_clear_wrapper_channel")); + Assert.That(stop, Does.Not.Contain("_bugsee_clear_bridge_wrapper_channel")); + Assert.That(stop, Does.Contain("SetPhase(NetworkLaunchPhase.Stopped)")); + + string 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")); + Assert.That(body, Does.Not.Contain("_bugsee_create_report")); + } + + [Test] + 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.Contain("_openReport = null")); + 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")); + Assert.That(body, Does.Contain("_networkLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped)")); + Assert.That(body, Does.Contain("_breadcrumbLaunchBuffer.SetPhase(NetworkLaunchPhase.Stopped)")); + } + + [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_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() + { + string ios = File.ReadAllText(RepoFile("Runtime/Platform/IOS/IOSBridge.cs")); + + string attachments = ExtractMethodBody(ios, "public IReadOnlyList Attachments"); + Assert.That(attachments, Does.Contain("IosLiveAttachment(iosAttachment)")); + Assert.That(ios, Does.Not.Contain("ApplyOpenReportToNative")); + + string create = ExtractMethodBody(ios, "public IAttachment AddAttachmentBytes(byte[] data, string name, string mimeType)"); + Assert.That(create, Does.Contain("as IosAttachment")); + } + } +} diff --git a/Tests~/WrapperPolicy/ReportPathSourceTests.cs b/Tests~/WrapperPolicy/ReportPathSourceTests.cs index 9dfcb18..7c9e23b 100644 --- a/Tests~/WrapperPolicy/ReportPathSourceTests.cs +++ b/Tests~/WrapperPolicy/ReportPathSourceTests.cs @@ -42,6 +42,59 @@ 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)")); + + 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] public void Ios_apply_report_dict_uses_path_and_bytes_attachments() { @@ -218,6 +271,31 @@ 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() + { + 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() {