diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 92cc0686..a2857979 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -208,3 +208,40 @@ jobs:
unzip -Z1 "${app_bundles[0]}" > "$entries"
grep -Eq '(^|/)lib/arm64-v8a/libil2cpp\.so$' "$entries"
grep -Eq '(^|/)lib/arm64-v8a/libbacktrace-native\.so$' "$entries"
+
+ dex_entries=()
+ while IFS= read -r entry; do
+ if [[ "$entry" =~ ^base/dex/classes([0-9]+)?\.dex$ ]]; then
+ dex_entries+=("$entry")
+ fi
+ done < "$entries"
+
+ if (( ${#dex_entries[@]} == 0 )); then
+ echo "Error: App Bundle contains no base/dex/classes*.dex entries." >&2
+ exit 1
+ fi
+
+ dex_dir="$RUNNER_TEMP/backtrace-aab-dex"
+ mkdir -p "$dex_dir"
+
+ handler_class='backtraceio/library/nativeCalls/BacktraceCrashHandler'
+ handler_descriptor="L${handler_class};"
+ handler_found=false
+
+ for dex_index in "${!dex_entries[@]}"; do
+ dex_entry="${dex_entries[$dex_index]}"
+ dex_file="$dex_dir/classes-$dex_index.dex"
+ unzip -p "${app_bundles[0]}" "$dex_entry" > "$dex_file"
+
+ if grep -aFq -- "$handler_descriptor" "$dex_file"; then
+ handler_found=true
+ break
+ fi
+ done
+
+ if [[ "$handler_found" != true ]]; then
+ echo "Error: App Bundle DEX files do not contain ${handler_class}." >&2
+ printf 'Searched DEX entries:\n' >&2
+ printf ' %s\n' "${dex_entries[@]}" >&2
+ exit 1
+ fi
diff --git a/Runtime/BacktraceClient.cs b/Runtime/BacktraceClient.cs
index a20dfd21..6df666c6 100644
--- a/Runtime/BacktraceClient.cs
+++ b/Runtime/BacktraceClient.cs
@@ -194,7 +194,10 @@ public bool SetAttribute(string key, string value)
AttributeProvider[key] = value;
if (_nativeClient != null)
{
- _nativeClient.SetAttribute(key, value);
+ NativeAttributeLifecycle.TrySetAttribute(
+ () => _nativeClient.SetAttribute(key, value),
+ NativeAttributeLifecycle.NativeAttributeFailureCode,
+ warning => Debug.LogWarning(warning));
}
return true;
}
@@ -368,6 +371,10 @@ internal INativeClient NativeClient
{
return _nativeClient;
}
+ set
+ {
+ _nativeClient = value;
+ }
}
public bool EnablePerformanceStatistics
diff --git a/Runtime/Native/Android/AndroidLoadedLibraryPath.cs b/Runtime/Native/Android/AndroidLoadedLibraryPath.cs
index 7abfdc5f..86199954 100644
--- a/Runtime/Native/Android/AndroidLoadedLibraryPath.cs
+++ b/Runtime/Native/Android/AndroidLoadedLibraryPath.cs
@@ -1,6 +1,8 @@
-#if UNITY_ANDROID
+#if UNITY_ANDROID || UNITY_EDITOR
using System;
+#if UNITY_ANDROID
using System.Runtime.InteropServices;
+#endif
namespace Backtrace.Unity.Runtime.Native.Android
{
@@ -13,6 +15,7 @@ namespace Backtrace.Unity.Runtime.Native.Android
///
internal static class AndroidLoadedLibraryPath
{
+#if UNITY_ANDROID
// RTLD_LAZY has the same value on 32- and 64-bit bionic.
// RTLD_NOW (2 on LP64) must NOT be used here: on LP32 bionic the value 2 means RTLD_GLOBAL,
// which would irreversibly promote every exported symbol of the already-loaded library into the global group and let later dlopen'ed libraries bind against them.
@@ -50,44 +53,51 @@ private struct DlInfo
///
internal static string TryGet()
{
- try
+ return TryGet(GetLoadedLibraryPath);
+ }
+
+ private static string GetLoadedLibraryPath()
+ {
+ lock (HandleLock)
{
- lock (HandleLock)
+ if (_libraryHandle == IntPtr.Zero)
{
- if (_libraryHandle == IntPtr.Zero)
- {
- _libraryHandle = DlOpen(NativeLibraryName, RtldLazy);
- }
- if (_libraryHandle == IntPtr.Zero)
- {
- return null;
- }
-
- IntPtr anchor = DlSym(_libraryHandle, AnchorSymbol);
- if (anchor == IntPtr.Zero)
- {
- return null;
- }
+ _libraryHandle = DlOpen(NativeLibraryName, RtldLazy);
+ }
+ if (_libraryHandle == IntPtr.Zero)
+ {
+ return null;
+ }
- DlInfo information;
- if (DlAddr(anchor, out information) == 0 || information.FileName == IntPtr.Zero)
- {
- return null;
- }
+ IntPtr anchor = DlSym(_libraryHandle, AnchorSymbol);
+ if (anchor == IntPtr.Zero)
+ {
+ return null;
+ }
- string path = Marshal.PtrToStringAnsi(information.FileName);
- return string.IsNullOrEmpty(path) ? null : path;
+ DlInfo information;
+ if (DlAddr(anchor, out information) == 0 || information.FileName == IntPtr.Zero)
+ {
+ return null;
}
+
+ return Marshal.PtrToStringAnsi(information.FileName);
}
- catch (DllNotFoundException)
- {
- return null;
- }
- catch (EntryPointNotFoundException)
+ }
+#endif
+
+ ///
+ /// Returns a non-empty path supplied by the provider, or null when the provider cannot produce one.
+ /// The provider boundary is deliberately broad because linker metadata is optional and fallback resolution must continue after any managed failure.
+ ///
+ internal static string TryGet(Func pathProvider)
+ {
+ try
{
- return null;
+ string path = pathProvider();
+ return string.IsNullOrEmpty(path) ? null : path;
}
- catch (SEHException)
+ catch (Exception)
{
return null;
}
diff --git a/Runtime/Native/Android/AndroidNativeInitialization.cs b/Runtime/Native/Android/AndroidNativeInitialization.cs
index 00253415..35d5716e 100644
--- a/Runtime/Native/Android/AndroidNativeInitialization.cs
+++ b/Runtime/Native/Android/AndroidNativeInitialization.cs
@@ -4,9 +4,9 @@
namespace Backtrace.Unity.Runtime.Native.Android
{
///
- /// Coordinates native-backend activation and the managed setup that follows it.
- /// The activation callback is invoked before JNI cleanup,
- /// a cleanup failure after a successful native initialization still rolls the backend back.
+ /// Coordinates native-bridge completion and the managed setup that follows it.
+ /// The completion callback is invoked before JNI cleanup so a false result or any later
+ /// cleanup or setup failure can roll back possible native side effects.
///
internal static class AndroidNativeInitialization
{
@@ -29,48 +29,62 @@ internal static bool Execute(
throw new ArgumentNullException("rollback");
}
- bool backendActive = false;
+ bool nativeBridgeCompleted = false;
try
{
- bool initialized = initialize(() => backendActive = true);
+ bool initialized = initialize(() => nativeBridgeCompleted = true);
if (!initialized)
{
+ if (nativeBridgeCompleted)
+ {
+ TryRollback(rollback, reportRollbackFailure);
+ }
+
return false;
}
- // Keep the transaction safe even if an initializer forgets to invoke the early activation callback after returning true.
- backendActive = true;
+ // Keep the transaction safe even if an initializer returns true without invoking the native-bridge completion callback.
+ nativeBridgeCompleted = true;
completeSetup();
return true;
}
catch
{
- if (backendActive)
+ if (nativeBridgeCompleted)
{
- try
- {
- rollback();
- }
- catch (Exception rollbackFailure)
- {
- // Rollback is best-effort. Preserve the setup exception, which is the actionable failure, while still allowing a contained diagnostic.
- if (reportRollbackFailure != null)
- {
- try
- {
- reportRollbackFailure(rollbackFailure);
- }
- catch (Exception)
- {
- // Diagnostics must never replace the setup failure.
- }
- }
- }
+ TryRollback(rollback, reportRollbackFailure);
}
throw;
}
}
+ private static void TryRollback(
+ Action rollback,
+ Action reportRollbackFailure)
+ {
+ try
+ {
+ rollback();
+ }
+ catch (Exception rollbackFailure)
+ {
+ // Rollback is best-effort. Preserve the initialization outcome while still allowing a contained diagnostic.
+ if (reportRollbackFailure == null)
+ {
+ return;
+ }
+
+ try
+ {
+ reportRollbackFailure(rollbackFailure);
+ }
+ catch (Exception)
+ {
+ // Diagnostics must never replace or escape the original initialization outcome.
+ }
+ }
+ }
+
///
/// Deletes every non-zero JNI local reference in the supplied order.
/// A failed deletion does not prevent later references from being released;
diff --git a/Runtime/Native/Android/NativeClient.cs b/Runtime/Native/Android/NativeClient.cs
index 3191342c..0741f721 100644
--- a/Runtime/Native/Android/NativeClient.cs
+++ b/Runtime/Native/Android/NativeClient.cs
@@ -1,4 +1,4 @@
-#if UNITY_ANDROID
+#if UNITY_ANDROID || UNITY_EDITOR
using Backtrace.Unity.Common;
using Backtrace.Unity.Extensions;
using Backtrace.Unity.Model;
@@ -22,6 +22,7 @@ namespace Backtrace.Unity.Runtime.Native.Android
internal sealed class NativeClient : NativeClientBase, INativeClient
{
private const string CallbackMethodName = "OnAnrDetected";
+ private const string NativeAttributeFailureCode = "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE";
// The P/Invoke declarations live in AndroidNativeInterop, which also compiles in the Editor and the EditMode signature tests can pin the corrected return types.
@@ -31,6 +32,8 @@ internal sealed class NativeClient : NativeClientBase, INativeClient
///
private readonly Dictionary _attributeMapping = new Dictionary();
+ private readonly Action _nativeAttributeWriter;
+
private void SetDefaultAttributeMaps()
{
_attributeMapping.Add("FDSize", "descriptor.count");
@@ -112,6 +115,7 @@ private void SetDefaultAttributeMaps()
public string GameObjectName { get; internal set; }
public NativeClient(BacktraceConfiguration configuration, BacktraceBreadcrumbs breadcrumbs, IDictionary clientAttributes, IEnumerable attachments, string gameObjectName) : base(configuration, breadcrumbs)
{
+ _nativeAttributeWriter = SetNativeAttribute;
GameObjectName = gameObjectName;
SetDefaultAttributeMaps();
if (!_enabled)
@@ -131,6 +135,23 @@ public NativeClient(BacktraceConfiguration configuration, BacktraceBreadcrumbs b
}
}
+ ///
+ /// Creates an enabled client around an instance-scoped attribute writer.
+ /// This keeps Editor tests on the concrete OOM path without invoking JNI.
+ ///
+ internal NativeClient(
+ BacktraceConfiguration configuration,
+ Action nativeAttributeWriter) : base(configuration, null)
+ {
+ if (nativeAttributeWriter == null)
+ {
+ throw new ArgumentNullException("nativeAttributeWriter");
+ }
+
+ _nativeAttributeWriter = nativeAttributeWriter;
+ CaptureNativeCrashes = true;
+ }
+
///
/// Setup communication between Unity and Android to receive information about unhandled thread exceptions
///
@@ -315,7 +336,7 @@ private static bool InvokeInitialize(
string[] attributeValues,
string[] attachments,
string[] environmentVariables,
- Action markNativeBackendActive)
+ Action markNativeBridgeCallCompleted)
{
IntPtr urlRef = IntPtr.Zero;
IntPtr databaseRef = IntPtr.Zero;
@@ -341,12 +362,9 @@ private static bool InvokeInitialize(
valuesRef,
attachmentsRef,
environmentRef);
- if (initialized)
- {
- // Record activation before local-reference cleanup.
- // If that cleanup throws, the caller must still disable the already-active backend.
- markNativeBackendActive();
- }
+ // Record native bridge completion before local-reference cleanup.
+ // Even a false result can leave native state that the transaction must roll back.
+ markNativeBridgeCallCompleted();
return initialized;
}
finally
@@ -439,7 +457,11 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt
// The authoritative linker answer is queried FIRST:
// it must remain usable even when process-ABI detection or application metadata is unavailable,
// and both of those lookups are fully fail-safe (they return null/empty instead of throwing).
+#if UNITY_ANDROID
var loadedLibraryPath = AndroidLoadedLibraryPath.TryGet();
+#else
+ string loadedLibraryPath = null;
+#endif
// The ABI of THIS PROCESS (not the device-preferred ABI: a 32-bit process on a 64-bit device differs).
// It is required only for the x86 policy and the split/base-APK path fallback,
@@ -453,7 +475,11 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt
return false;
}
+#if UNITY_ANDROID
var applicationInfo = AndroidApplicationInfoSnapshot.Capture();
+#else
+ var applicationInfo = new AndroidApplicationInfoSnapshot();
+#endif
if (string.IsNullOrEmpty(applicationInfo.NativeLibraryDir))
{
// Legacy discovery for hosts without a Unity activity.
@@ -478,7 +504,7 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt
BuildLibrarySearchPaths(applicationInfo.NativeLibraryDir));
var initialized = AndroidNativeInitialization.Execute(
- markNativeBackendActive => InvokeInitialize(
+ markNativeBridgeCallCompleted => InvokeInitialize(
minidumpUrl,
databasePath,
_crashHandlerPath,
@@ -486,7 +512,7 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt
new string[0],
attachments == null ? new string[0] : attachments.ToArray(),
environmentVariables,
- markNativeBackendActive),
+ markNativeBridgeCallCompleted),
() =>
{
foreach (var attribute in backtraceAttributes)
@@ -512,6 +538,7 @@ private bool TryInitializeNativeCrashes(IDictionary backtraceAtt
private static string TryGetProcessAbi()
{
+#if UNITY_ANDROID
try
{
return AndroidProcessAbi.Capture();
@@ -520,6 +547,9 @@ private static string TryGetProcessAbi()
{
return null;
}
+#else
+ return null;
+#endif
}
private List BuildLibrarySearchPaths(string nativeLibraryDir)
@@ -688,7 +718,10 @@ public void SetAttribute(string key, string value)
return;
}
// avoid null reference in crashpad source code
- SetNativeAttribute(key, value ?? string.Empty);
+ NativeAttributeLifecycle.TrySetAttribute(
+ () => _nativeAttributeWriter(key, value ?? string.Empty),
+ NativeAttributeFailureCode,
+ warning => Debug.LogWarning(warning));
}
///
diff --git a/Runtime/Native/Base/NativeClientBase.cs b/Runtime/Native/Base/NativeClientBase.cs
index df707e31..13b894e3 100644
--- a/Runtime/Native/Base/NativeClientBase.cs
+++ b/Runtime/Native/Base/NativeClientBase.cs
@@ -1,4 +1,4 @@
-#if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_WIN || UNITY_GAMECORE_XBOXSERIES || UNITY_GAMECORE_XBOXONE || UNITY_STANDALONE_OSX
+#if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_WIN || UNITY_GAMECORE_XBOXSERIES || UNITY_GAMECORE_XBOXONE || UNITY_STANDALONE_OSX || UNITY_EDITOR
using Backtrace.Unity.Model;
using Backtrace.Unity.Model.Breadcrumbs;
using Backtrace.Unity.Extensions;
@@ -130,4 +130,4 @@ private bool ShouldStoreAnrBreadcrumbs()
}
}
}
-#endif
\ No newline at end of file
+#endif
diff --git a/Runtime/Native/NativeAttributeLifecycle.cs b/Runtime/Native/NativeAttributeLifecycle.cs
new file mode 100644
index 00000000..a53c80a4
--- /dev/null
+++ b/Runtime/Native/NativeAttributeLifecycle.cs
@@ -0,0 +1,58 @@
+using System;
+
+namespace Backtrace.Unity.Runtime.Native
+{
+ ///
+ /// Contains optional native attribute writes so platform or diagnostic failures cannot escape into the host game.
+ /// Delegates keep this policy testable in the Editor.
+ ///
+ internal static class NativeAttributeLifecycle
+ {
+ internal const string NativeAttributeFailureCode = "BT_UNITY_NATIVE_ATTRIBUTE_FAILURE";
+
+ internal static bool TrySetAttribute(
+ Action setAttribute,
+ string failureCode,
+ Action logWarning)
+ {
+ if (setAttribute == null)
+ {
+ throw new ArgumentNullException("setAttribute");
+ }
+ if (string.IsNullOrEmpty(failureCode))
+ {
+ throw new ArgumentException("A native attribute failure code is required", "failureCode");
+ }
+
+ try
+ {
+ setAttribute();
+ return true;
+ }
+ catch (Exception exception)
+ {
+ LogFailure(failureCode, exception, logWarning);
+ return false;
+ }
+ }
+
+ private static void LogFailure(
+ string failureCode,
+ Exception exception,
+ Action logWarning)
+ {
+ var failureType = exception == null ? "unknown" : exception.GetType().FullName;
+ try
+ {
+ if (logWarning != null)
+ {
+ logWarning(failureCode + ": Failure type: " + failureType);
+ }
+ }
+ catch (Exception)
+ {
+ // Diagnostics are optional and must not let a native attribute failure escape.
+ }
+ }
+ }
+}
diff --git a/Runtime/Native/NativeAttributeLifecycle.cs.meta b/Runtime/Native/NativeAttributeLifecycle.cs.meta
new file mode 100644
index 00000000..89ecaffe
--- /dev/null
+++ b/Runtime/Native/NativeAttributeLifecycle.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9252dbbdc2ea4dd6b819069196326f2d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Runtime/BacktraceAttributeTests.cs b/Tests/Runtime/BacktraceAttributeTests.cs
index 35bf1160..6d77cdfa 100644
--- a/Tests/Runtime/BacktraceAttributeTests.cs
+++ b/Tests/Runtime/BacktraceAttributeTests.cs
@@ -2,6 +2,7 @@
using Backtrace.Unity.Model;
using Backtrace.Unity.Model.Attributes;
using Backtrace.Unity.Model.JsonData;
+using Backtrace.Unity.Runtime.Native;
using NUnit.Framework;
using System;
using System.Collections;
@@ -17,6 +18,41 @@ public class BacktraceAttributeTests : BacktraceBaseTest
{
private const int CLIENT_RATE_LIMIT = 3;
+ private sealed class FirstAttributeThrowingNativeClient : INativeClient
+ {
+ internal int SetAttributeCalls { get; private set; }
+
+ public void Disable()
+ { }
+
+ public void GetAttributes(IDictionary attributes)
+ { }
+
+ public void HandleAnr()
+ { }
+
+ public bool OnOOM()
+ {
+ return false;
+ }
+
+ public void PauseAnrThread(bool state)
+ { }
+
+ public void SetAttribute(string key, string value)
+ {
+ SetAttributeCalls++;
+ if (SetAttributeCalls == 1)
+ {
+ throw new InvalidOperationException(
+ "native attribute failed for secret-key=secret-value at /private/data/backtrace https://submit.example.test/token");
+ }
+ }
+
+ public void Update(float time)
+ { }
+ }
+
[SetUp]
public void Setup()
{
@@ -129,6 +165,36 @@ public IEnumerator TesClientAttributesMethod_BacktraceDataShouldIncludeClientAtt
yield return null;
}
+ [Test]
+ public void SetAttributes_NativeClientFailure_DoesNotInterruptManagedAttributes()
+ {
+ var nativeClient = new FirstAttributeThrowingNativeClient();
+ BacktraceClient.NativeClient = nativeClient;
+ var attributes = new Dictionary
+ {
+ { "first-managed-attribute", "first-value" },
+ { "second-managed-attribute", "second-value" }
+ };
+
+ Debug.unityLogger.logEnabled = true;
+ try
+ {
+ LogAssert.Expect(
+ LogType.Warning,
+ "BT_UNITY_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException");
+ Assert.DoesNotThrow(() => BacktraceClient.SetAttributes(attributes));
+ }
+ finally
+ {
+ Debug.unityLogger.logEnabled = false;
+ }
+
+ Assert.AreEqual(2, nativeClient.SetAttributeCalls);
+ var managedAttributes = BacktraceClient.AttributeProvider.GenerateAttributes(false);
+ Assert.AreEqual("first-value", managedAttributes["first-managed-attribute"]);
+ Assert.AreEqual("second-value", managedAttributes["second-managed-attribute"]);
+ }
+
[UnityTest]
public IEnumerator TestAttributesGeneration_CreateCorrectAttributes_WithDiffrentReportConfiguration()
diff --git a/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs
new file mode 100644
index 00000000..64bff44b
--- /dev/null
+++ b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs
@@ -0,0 +1,39 @@
+#if UNITY_ANDROID || UNITY_EDITOR
+using System;
+using Backtrace.Unity.Runtime.Native.Android;
+using NUnit.Framework;
+
+namespace Backtrace.Unity.Tests.Runtime
+{
+ ///
+ /// Tests the optional linker-path provider boundary without loading libdl in the editor.
+ ///
+ public class AndroidLoadedLibraryPathTests
+ {
+ [Test]
+ public void ProviderPathIsPreserved()
+ {
+ const string expected = "/data/app/example/lib/arm64/libbacktrace-native.so";
+
+ string result = AndroidLoadedLibraryPath.TryGet(() => expected);
+
+ Assert.AreEqual(expected, result);
+ }
+
+ [Test]
+ public void EmptyProviderPathReturnsNull()
+ {
+ Assert.IsNull(AndroidLoadedLibraryPath.TryGet(() => string.Empty));
+ }
+
+ [Test]
+ public void UnexpectedProviderExceptionReturnsNull()
+ {
+ string result = AndroidLoadedLibraryPath.TryGet(
+ () => { throw new InvalidOperationException("provider failed"); });
+
+ Assert.IsNull(result);
+ }
+ }
+}
+#endif
diff --git a/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs.meta b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs.meta
new file mode 100644
index 00000000..cf01f4d0
--- /dev/null
+++ b/Tests/Runtime/Native/Android/AndroidLoadedLibraryPathTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d9e30f855f9b4e1da0c6146bf6b8a071
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs
new file mode 100644
index 00000000..5adcb530
--- /dev/null
+++ b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs
@@ -0,0 +1,63 @@
+#if UNITY_ANDROID || UNITY_EDITOR
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Backtrace.Unity.Model;
+using Backtrace.Unity.Runtime.Native.Android;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Backtrace.Unity.Tests.Runtime
+{
+ public class AndroidNativeClientOomTests
+ {
+ [Test]
+ public void OnOomAttemptsTimestampAfterFirstAttributeWriteFails()
+ {
+ var attempts = new List>();
+ var configuration = ScriptableObject.CreateInstance();
+ try
+ {
+ var client = new NativeClient(
+ configuration,
+ (key, value) =>
+ {
+ attempts.Add(new KeyValuePair(key, value));
+ if (attempts.Count == 1)
+ {
+ throw new InvalidOperationException("first native attribute failed");
+ }
+ });
+
+ LogAssert.Expect(
+ LogType.Warning,
+ "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException");
+
+ bool result = false;
+ Assert.DoesNotThrow(() => { result = client.OnOOM(); });
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(2, attempts.Count);
+ Assert.AreEqual("memory.warning", attempts[0].Key);
+ Assert.AreEqual("true", attempts[0].Value);
+ Assert.AreEqual("memory.warning.date", attempts[1].Key);
+
+ int timestamp;
+ Assert.IsTrue(
+ int.TryParse(
+ attempts[1].Value,
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out timestamp),
+ "The second attribute must contain an invariant Unix timestamp.");
+ Assert.Greater(timestamp, 0);
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(configuration);
+ }
+ }
+ }
+}
+#endif
diff --git a/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs.meta b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs.meta
new file mode 100644
index 00000000..a7a14f86
--- /dev/null
+++ b/Tests/Runtime/Native/Android/AndroidNativeClientOomTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 093f8f7bbc1c42c9b93611ac4ec1851c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs b/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs
index 3972a372..a0eec492 100644
--- a/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs
+++ b/Tests/Runtime/Native/Android/AndroidNativeInitializationTests.cs
@@ -9,20 +9,63 @@ namespace Backtrace.Unity.Tests.Runtime
public class AndroidNativeInitializationTests
{
[Test]
- public void RejectedInitializationDoesNotCompleteOrRollback()
+ public void RejectedBeforeNativeBridgeDoesNotRollback()
+ {
+ int rollbackCount = 0;
+
+ bool initialized = AndroidNativeInitialization.Execute(
+ markCompleted => false,
+ () => Assert.Fail("Completion must not run"),
+ () => rollbackCount++,
+ null);
+
+ Assert.IsFalse(initialized);
+ Assert.AreEqual(0, rollbackCount);
+ }
+
+ [Test]
+ public void NativeBridgeFalseResultRollsBackPartialState()
{
int completionCount = 0;
int rollbackCount = 0;
bool initialized = AndroidNativeInitialization.Execute(
- markActive => false,
+ markCompleted =>
+ {
+ markCompleted();
+ return false;
+ },
() => completionCount++,
() => rollbackCount++,
null);
Assert.IsFalse(initialized);
Assert.AreEqual(0, completionCount);
- Assert.AreEqual(0, rollbackCount);
+ Assert.AreEqual(1, rollbackCount);
+ }
+
+ [Test]
+ public void NativeBridgeFalseResultRollbackFailureIsContained()
+ {
+ var rollbackFailure = new InvalidOperationException("sensitive rollback detail");
+ Exception reported = null;
+
+ Assert.DoesNotThrow(() =>
+ {
+ bool initialized = AndroidNativeInitialization.Execute(
+ markCompleted =>
+ {
+ markCompleted();
+ return false;
+ },
+ () => Assert.Fail("Completion must not run"),
+ () => { throw rollbackFailure; },
+ failure => reported = failure);
+
+ Assert.IsFalse(initialized);
+ });
+
+ Assert.AreSame(rollbackFailure, reported);
}
[Test]
diff --git a/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs
new file mode 100644
index 00000000..8247605c
--- /dev/null
+++ b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using Backtrace.Unity.Runtime.Native;
+using NUnit.Framework;
+
+namespace Backtrace.Unity.Tests.Runtime
+{
+ public class NativeAttributeLifecycleTests
+ {
+ private const string AndroidFailureCode = "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE";
+
+ [Test]
+ public void NativeAttributeFailureIsContainedAndLogsOnlyFailureType()
+ {
+ const string sensitiveKey = "secret-key";
+ const string sensitiveValue = "secret-value";
+ const string sensitivePath = "/private/data/backtrace";
+ const string sensitiveUrl = "https://submit.example.test/token";
+ var warnings = new List();
+
+ bool result = true;
+ Assert.DoesNotThrow(() => result = NativeAttributeLifecycle.TrySetAttribute(
+ () => { throw new InvalidOperationException(
+ sensitiveKey + " " + sensitiveValue + " " + sensitivePath + " " + sensitiveUrl); },
+ AndroidFailureCode,
+ warnings.Add));
+
+ Assert.IsFalse(result);
+ CollectionAssert.AreEqual(
+ new[] { "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException" },
+ warnings);
+ StringAssert.DoesNotContain(sensitiveKey, warnings[0]);
+ StringAssert.DoesNotContain(sensitiveValue, warnings[0]);
+ StringAssert.DoesNotContain(sensitivePath, warnings[0]);
+ StringAssert.DoesNotContain(sensitiveUrl, warnings[0]);
+ }
+
+ [Test]
+ public void FailedAttributeDoesNotPreventLaterAttributeOperations()
+ {
+ int attempts = 0;
+ var warnings = new List();
+
+ bool firstResult = NativeAttributeLifecycle.TrySetAttribute(
+ () =>
+ {
+ attempts++;
+ throw new InvalidOperationException("first attribute failed");
+ },
+ AndroidFailureCode,
+ warnings.Add);
+
+ Assert.IsFalse(firstResult);
+ Assert.AreEqual(1, warnings.Count);
+ warnings.Clear();
+
+ bool secondResult = NativeAttributeLifecycle.TrySetAttribute(
+ () => attempts++,
+ AndroidFailureCode,
+ warnings.Add);
+
+ Assert.IsTrue(secondResult);
+ Assert.AreEqual(2, attempts);
+ Assert.IsEmpty(warnings, "A successful native attribute write must not log a warning.");
+ }
+
+ [Test]
+ public void LoggingFailureDoesNotEscape()
+ {
+ Assert.DoesNotThrow(() => NativeAttributeLifecycle.TrySetAttribute(
+ () => { throw new InvalidOperationException("native attribute failed"); },
+ AndroidFailureCode,
+ warning => { throw new ApplicationException("logger failed"); }));
+ }
+
+ [Test]
+ public void ContainedAndroidFailureDoesNotProduceDuplicateGenericWarning()
+ {
+ var warnings = new List();
+
+ bool result = NativeAttributeLifecycle.TrySetAttribute(
+ () => NativeAttributeLifecycle.TrySetAttribute(
+ () => { throw new InvalidOperationException("native attribute failed"); },
+ AndroidFailureCode,
+ warnings.Add),
+ NativeAttributeLifecycle.NativeAttributeFailureCode,
+ warnings.Add);
+
+ Assert.IsTrue(result);
+ CollectionAssert.AreEqual(
+ new[] { "BT_UNITY_ANDROID_NATIVE_ATTRIBUTE_FAILURE: Failure type: System.InvalidOperationException" },
+ warnings);
+ }
+ }
+}
diff --git a/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs.meta b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs.meta
new file mode 100644
index 00000000..9bfd1cad
--- /dev/null
+++ b/Tests/Runtime/Native/NativeAttributeLifecycleTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 51d47a47036c4df4a934a5da833ee2b1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: