diff --git a/CHANGELOG.md b/CHANGELOG.md index 5afa4696c..f1b1c36f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,13 @@ ### Fixes +- App hang detection no longer reports a false app hang after the game moves to the background. It now stays paused while the game is in the background or out of focus. ([#2895](https://github.com/getsentry/sentry-unity/pull/2895)) - The Linux native SDK now loads on distros with older glibc versions, such as Ubuntu 22.04 and Debian 12. ([#2894](https://github.com/getsentry/sentry-unity/pull/2894)) - Attaching a screenshot no longer stalls the main thread. Unless `SetBeforeSendScreenshot` is set, the frame gets read back from the GPU asynchronously and encoded on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Features -- Added experimental support for Unity as a Library on iOS. With `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-cocoa SDK that the app already started instead of bundling and starting its own. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885)) +- Added experimental support for Unity as a Library on Android and iOS. With `AndroidNativeInitializationType` or `IosNativeInitializationType` set to `NativeInitializationType.External`, the game attaches to the sentry-android or sentry-cocoa SDK that the app already started instead of bundling and starting its own. On Android, when the game runs in its own process, it starts sentry-android there and continues the app's trace. Native crashes carry the game's context and breadcrumbs, C# events and logs use the app's release, environment, user, and trace, the game's transactions join the app's trace, and the game's debug symbols are still uploaded automatically. ([#2885](https://github.com/getsentry/sentry-unity/pull/2885), [#2895](https://github.com/getsentry/sentry-unity/pull/2895)) - The SDK now supports Linux ARM64 dedicated servers (Unity 6000.3 and newer, IL2CPP). The native SDK for both backends ships for `x86_64` and `ARM64`, and the build picks the one matching the player. ([#2894](https://github.com/getsentry/sentry-unity/pull/2894)) ### Dependencies diff --git a/docs/agent-guides/platform-android.md b/docs/agent-guides/platform-android.md index 0f3b4f4f7..cf85d0fe1 100644 --- a/docs/agent-guides/platform-android.md +++ b/docs/agent-guides/platform-android.md @@ -12,6 +12,7 @@ - Runtime initialization writes `io.sentry.auto-init=false`; C# initializes the Java SDK later. - Build-time initialization writes options to the Android manifest and lets Java auto-initialize. - Runtime option changes cannot alter build-time initialization behavior. +- External initialization (Unity as a Library, experimental): the export ships no SDK, writes no manifest entries (they would merge into the app's), and keeps the ProGuard consumer rules. At runtime the game attaches if sentry-android is enabled in its process (no user, environment, or trace sync, no close, no NDK app-hang heartbeat). In a secondary process (e.g. `:Unity`), where the app didn't initialize it, the game initializes it from the Unity options with its own cache directory (`cache/sentry-`), so it doesn't take over the main process's session and persisted scope, and with ANR and tombstone reporting off, since the main process's SDK reports the exits of all the app's processes. There it continues the main process's trace, read from that SDK's persisted scope (`cache/sentry//.scope-cache/trace.json`) at start and on resume, and turns off its own trace generation. The symbol upload task goes into `unityLibrary/build.gradle` and runs after `mergeJniLibFolders`, since the app never builds `launcher`. In the main process without an SDK, native support is skipped. The process name comes from `Application.getProcessName` (API 28+) or `/proc/self/cmdline` below. C# sessions are off and the app's release and environment are used unless set. ## Export Processing diff --git a/package-dev/Plugins/Android/proguard-sentry-unity.pro b/package-dev/Plugins/Android/proguard-sentry-unity.pro index 2d60bf651..9567b3b69 100644 --- a/package-dev/Plugins/Android/proguard-sentry-unity.pro +++ b/package-dev/Plugins/Android/proguard-sentry-unity.pro @@ -6,6 +6,14 @@ -keep class io.sentry.SentryLevel { *; } -keep class io.sentry.SentryOptions { *; } -keep class io.sentry.Hub { *; } +-keep class io.sentry.IHub { *; } +-keep class io.sentry.IScopes { *; } +-keep class io.sentry.HubScopesWrapper { *; } +-keep class io.sentry.SentryTraceHeader { *; } +-keep class io.sentry.BaggageHeader { *; } +-keep class io.sentry.SpanContext { *; } +-keep class io.sentry.ISerializer { *; } +-keep class io.sentry.JsonSerializer { *; } -keep class io.sentry.Breadcrumb { *; } -keep class io.sentry.Scope { *; } -keep class io.sentry.ScopeCallback { *; } diff --git a/package-dev/Runtime/Sentry.Unity.Android.dll.meta b/package-dev/Runtime/Sentry.Unity.Android.dll.meta index 60106f4f9..a5fde1b78 100644 --- a/package-dev/Runtime/Sentry.Unity.Android.dll.meta +++ b/package-dev/Runtime/Sentry.Unity.Android.dll.meta @@ -17,7 +17,7 @@ PluginImporter: enabled: 0 settings: Exclude Android: 0 - Exclude Editor: 1 + Exclude Editor: 0 Exclude Linux64: 1 Exclude Lumin: 1 Exclude OSXUniversal: 1 @@ -40,7 +40,7 @@ PluginImporter: - first: Editor: Editor second: - enabled: 0 + enabled: 1 settings: CPU: AnyCPU DefaultValueInitialized: true diff --git a/package/Runtime/Sentry.Unity.Android.dll.meta b/package/Runtime/Sentry.Unity.Android.dll.meta new file mode 100644 index 000000000..60106f4f9 --- /dev/null +++ b/package/Runtime/Sentry.Unity.Android.dll.meta @@ -0,0 +1,97 @@ +fileFormatVersion: 2 +guid: b92828d78a78242869a0fbbb954a1cc4 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 0 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude Lumin: 1 + Exclude OSXUniversal: 1 + Exclude WebGL: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + Exclude tvOS: 1 + - first: + Android: Android + second: + enabled: 1 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: None + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: None + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 0 + settings: + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs b/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs new file mode 100644 index 000000000..6b5cb9396 --- /dev/null +++ b/src/Sentry.Unity.Android/ExternalAndroidJavaScopeObserver.cs @@ -0,0 +1,31 @@ +namespace Sentry.Unity.Android; + +/// +/// Scope sync for . The game follows the app's trace. When attached to +/// the app's SDK, the app also owns the user. In its own process, the game's SDK needs it. +/// +internal sealed class ExternalAndroidJavaScopeObserver : AndroidJavaScopeObserver +{ + private readonly bool _isAttached; + + public ExternalAndroidJavaScopeObserver(SentryOptions options, ISentryJava sentryJava, bool isAttached) + : base(options, sentryJava) => _isAttached = isAttached; + + public override void SetUserImpl(SentryUser user) + { + if (!_isAttached) + { + base.SetUserImpl(user); + } + } + + public override void UnsetUserImpl() + { + if (!_isAttached) + { + base.UnsetUserImpl(); + } + } + + public override void SetTraceImpl(SentryId traceId, SpanId spanId) { } +} diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs index a8b0b58cf..2afdfb24d 100644 --- a/src/Sentry.Unity.Android/SentryJava.cs +++ b/src/Sentry.Unity.Android/SentryJava.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.IO; using System.Runtime.CompilerServices; using System.Threading; using Sentry.Extensibility; @@ -10,7 +11,14 @@ namespace Sentry.Unity.Android; internal interface ISentryJava { public bool? IsEnabled(); + public bool? IsMainProcess(); public void Init(SentryUnityOptions options); + public string? GetOption(string getter); + public string? GetTraceHeader(); + public string? GetBaggageHeader(); + public string? GetMainProcessTraceHeader(); + public SentryUser? GetUser(); + public bool TryGetMainProcessUser(out SentryUser? user); public string? GetInstallationId(); public bool? CrashedLastRun(); public void Close(); @@ -65,11 +73,18 @@ internal class SentryJava : ISentryJava private readonly AutoResetEvent _scopeSyncEvent; private readonly Thread _scopeSyncThread; private readonly ConcurrentQueue<(Action action, string actionName)> _scopeSyncItems = new(); + private static readonly TimeSpan JniResultTimeout = TimeSpan.FromMilliseconds(500); private volatile bool _closed; private static AndroidJavaObject GetInternalSentryJava() => new AndroidJavaClass("io.sentry.android.core.InternalSentrySdk"); private static AndroidJavaObject GetSentryJava() => new AndroidJavaClass("io.sentry.Sentry"); + private static AndroidJavaObject GetCurrentActivity() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + return unityPlayer.GetStatic("currentActivity"); + } + public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null) { _logger = logger; @@ -102,6 +117,50 @@ public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null) return null; } + // Whether the game runs in the app's main process. With Unity as a Library, the game can run in its own process. + public bool? IsMainProcess() + { + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling IsMainProcess() on Android SDK requires running on MainThread"); + return null; + } + + try + { + if (GetProcessName() is not { } processName) + { + return null; + } + + using var activity = GetCurrentActivity(); + return processName == activity.Call("getPackageName"); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.IsMainProcess' failed."); + } + + return null; + } + + // `Application.getProcessName` requires API 28. Below, the process name is the NUL-terminated `/proc/self/cmdline`. + private static string? GetProcessName() + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + if (version.GetStatic("SDK_INT") < 28) + { +#pragma warning disable SN0001 + var cmdline = File.ReadAllText("/proc/self/cmdline"); +#pragma warning restore SN0001 + var end = cmdline.IndexOf('\0'); + return end < 0 ? cmdline : cmdline.Substring(0, end); + } + + using var application = new AndroidJavaClass("android.app.Application"); + return application.CallStatic("getProcessName"); + } + public void Init(SentryUnityOptions options) { if (!MainThreadData.IsMainThread()) @@ -113,16 +172,24 @@ public void Init(SentryUnityOptions options) try { using var sentry = new AndroidJavaClass("io.sentry.android.core.SentryAndroid"); - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var context = unityPlayer.GetStatic("currentActivity"); + using var context = GetCurrentActivity(); sentry.CallStatic("init", context, new AndroidOptionsConfiguration(androidOptions => { androidOptions.Call("setDsn", options.Dsn); androidOptions.Call("setDebug", options.Debug); - androidOptions.Call("setRelease", options.Release); + + // With external initialization, the native SDK keeps the app's release and environment unless they were set. + var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; + if (!isExternal || options.ReleaseOverridden || options.Release != options.DefaultRelease) + { + androidOptions.Call("setRelease", options.Release); + } androidOptions.Call("setDist", options.Distribution); - androidOptions.Call("setEnvironment", options.Environment); + if (!isExternal || options.EnvironmentOverridden || options.Environment != options.DefaultEnvironment) + { + androidOptions.Call("setEnvironment", options.Environment); + } using var sentryLevelClass = new AndroidJavaClass("io.sentry.SentryLevel"); var levelString = GetLevelString(options.DiagnosticLevel); @@ -152,6 +219,38 @@ public void Init(SentryUnityOptions options) androidOptions.Call("setTombstoneEnabled", options.AndroidTombstoneEnabled); androidOptions.Call("setReportHistoricalTombstones", options.AndroidReportHistoricalTombstones); + // With external initialization, the game runs in its own process, next to the app's SDK in the main + // process. Its own cache directory keeps it from taking over the app's session and persisted scope. + if (isExternal && GetProcessName() is { } processName) + { + // Its events belong to the app's release and environment, persisted by the app's SDK. + if (!options.ReleaseOverridden && options.Release == options.DefaultRelease && ReadMainProcessOption(androidOptions, "release.json") is { } release) + { + androidOptions.Call("setRelease", release); + if (options.Distribution is null) + { + androidOptions.Call("setDist", ReadMainProcessOption(androidOptions, "dist.json")); + } + } + if (!options.EnvironmentOverridden && options.Environment == options.DefaultEnvironment && ReadMainProcessOption(androidOptions, "environment.json") is { } environment) + { + androidOptions.Call("setEnvironment", environment); + } + + using var cacheDir = context.Call("getCacheDir"); + var processSuffix = processName.Substring(processName.IndexOf(':') + 1); + androidOptions.Call("setCacheDirPath", $"{cacheDir.Call("getAbsolutePath")}/sentry-{processSuffix}"); + // The game continues the app's trace instead of starting new ones on its own screens and taps. + androidOptions.Call("setEnableAutoTraceIdGeneration", false); + // From API 30, the app's SDK reports the exits of all of the app's processes, including this one. + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + if (version.GetStatic("SDK_INT") >= 30) + { + androidOptions.Call("setAnrEnabled", false); + androidOptions.Call("setTombstoneEnabled", false); + } + } + using (var logsOptions = androidOptions.Call("getLogs")) { logsOptions.Call("setEnabled", options.EnableLogs); @@ -176,11 +275,12 @@ public void Init(SentryUnityOptions options) } } - public string? GetInstallationId() + // A string option of the native SDK, e.g. `getRelease` or `getEnvironment`. + public string? GetOption(string getter) { if (!MainThreadData.IsMainThread()) { - _logger?.LogError("Calling GetInstallationId() on Android SDK requires running on MainThread"); + _logger?.LogError("Calling GetOption() on Android SDK requires running on MainThread"); return null; } @@ -189,16 +289,171 @@ public void Init(SentryUnityOptions options) using var sentry = GetSentryJava(); using var hub = sentry.CallStatic("getCurrentHub"); using var options = hub?.Call("getOptions"); - return options?.Call("getDistinctId"); + return options?.Call(getter); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.GetOption' failed."); + } + + return null; + } + + // The `sentry-trace` header of the app's SDK in the main process: its active span, otherwise its propagation context. + public string? GetMainProcessTraceHeader() + { + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling GetMainProcessTraceHeader() on Android SDK requires running on MainThread"); + return null; + } + + try + { + using var sentry = GetSentryJava(); + using var hub = sentry.CallStatic("getCurrentHub"); + using var options = hub.Call("getOptions"); + using var spanContext = ReadMainProcessFile(options, ".scope-cache/trace.json", "io.sentry.SpanContext"); + if (spanContext is null) + { + return null; + } + + using var traceId = spanContext.Call("getTraceId"); + using var spanId = spanContext.Call("getSpanId"); + using var sampled = spanContext.Call("getSampled"); + var header = $"{traceId.Call("toString")}-{spanId.Call("toString")}"; + return sampled is null ? header : $"{header}-{(sampled.Call("booleanValue") ? 1 : 0)}"; + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.GetMainProcessTraceHeader' failed."); + } + + return null; + } + + // The user the app's SDK in the main process persisted, null when it has none. False when the file can't be read, + // e.g. while the app's SDK rewrites it. + public bool TryGetMainProcessUser(out SentryUser? user) + { + user = null; + if (!MainThreadData.IsMainThread()) + { + _logger?.LogError("Calling TryGetMainProcessUser() on Android SDK requires running on MainThread"); + return false; + } + + try + { + using var sentry = GetSentryJava(); + using var hub = sentry.CallStatic("getCurrentHub"); + using var options = hub.Call("getOptions"); + using var javaUser = ReadMainProcessFile(options, ".scope-cache/user.json", "io.sentry.protocol.User", out var failed); + if (failed) + { + return false; + } + + user = ToSentryUser(javaUser); + return true; + } + catch (Exception e) + { + _logger?.LogError(e, "Calling 'SentryJava.TryGetMainProcessUser' failed."); + } + + return false; + } + + private static SentryUser? ToSentryUser(AndroidJavaObject? user) => user is null + ? null + : new SentryUser + { + Id = user.Call("getId"), + Username = user.Call("getUsername"), + Email = user.Call("getEmail"), + IpAddress = user.Call("getIpAddress") + }; + + private string? ReadMainProcessOption(AndroidJavaObject options, string fileName) + { + using var value = ReadMainProcessFile(options, $".options-cache/{fileName}", "java.lang.String"); + return value?.Call("toString"); + } + + // A file that the app's SDK in the main process persisted to its cache directory, next to ours: same DSN hash, + // default 'sentry' directory. + private AndroidJavaObject? ReadMainProcessFile(AndroidJavaObject options, string path, string className) => + ReadMainProcessFile(options, path, className, out _); + + // Failed when the file is there but can't be read, e.g. empty or partly written. + private AndroidJavaObject? ReadMainProcessFile(AndroidJavaObject options, string path, string className, out bool failed) + { + failed = false; + try + { + using var cacheDir = new AndroidJavaObject("java.io.File", options.Call("getCacheDirPath")); + using var activity = GetCurrentActivity(); + using var appCacheDir = activity.Call("getCacheDir"); + var filePath = $"{appCacheDir.Call("getAbsolutePath")}/sentry/{cacheDir.Call("getName")}/{path}"; + using var file = new AndroidJavaObject("java.io.File", filePath); + if (!file.Call("exists")) + { + _logger?.LogDebug("Nothing persisted by the main process at '{0}'.", filePath); + return null; + } + + using var fileReader = new AndroidJavaObject("java.io.FileReader", file); + using var reader = new AndroidJavaObject("java.io.BufferedReader", fileReader); + try + { + using var serializer = options.Call("getSerializer"); + using var javaClass = new AndroidJavaClass("java.lang.Class"); + using var type = javaClass.CallStatic("forName", className); + var value = serializer.Call("deserialize", reader, type); + failed = value is null; + return value; + } + finally + { + reader.Call("close"); + } } catch (Exception e) { - _logger?.LogError(e, "Calling 'SentryJava.GetInstallationId' failed."); + _logger?.LogError(e, "Reading '{0}' persisted by the main process failed.", path); } + failed = true; return null; } + public string? GetTraceHeader() => RunJniSafeAndWait(() => + { + using var sentry = GetSentryJava(); + using var header = sentry.CallStatic("getTraceparent"); + return header?.Call("getValue"); + }); + + // The user on the native SDK's scope. + public SentryUser? GetUser() => RunJniSafeAndWait(() => + { + using var sentry = GetInternalSentryJava(); + using var scope = sentry.CallStatic("getCurrentScope"); + using var user = scope?.Call("getUser"); + return ToSentryUser(user); + }); + + public string? GetBaggageHeader() => RunJniSafeAndWait(() => + { + using var sentry = GetSentryJava(); + using var header = sentry.CallStatic("getBaggage"); + return header?.Call("getValue"); + }); + + public string? GetInstallationId() => GetOption("getDistinctId"); + /// /// Returns whether the last run resulted in a crash. /// @@ -475,6 +730,52 @@ internal void RunJniSafe(Action action, [CallerMemberName] string actionName = " } } + // Like `RunJniSafe`, but waits for the result. Off the main thread, the call runs on the scope sync thread, which + // is attached to the JVM. + internal T? RunJniSafeAndWait(Func func, [CallerMemberName] string actionName = "", bool? isMainThread = null) + { + if (_closed) + { + return default; + } + + isMainThread ??= MainThreadData.IsMainThread(); + if (isMainThread is true || Thread.CurrentThread == _scopeSyncThread) + { + try + { + return func(); + } + catch (Exception e) + { + _logger?.LogError(e, "Calling '{0}' failed.", actionName); + return default; + } + } + + T? result = default; + var done = new ManualResetEventSlim(false); + _scopeSyncItems.Enqueue((() => + { + try + { + result = func(); + } + finally + { + done.Set(); + } + }, actionName)); + _scopeSyncEvent.Set(); + + if (!done.Wait(JniResultTimeout)) + { + _logger?.LogWarning("Timed out waiting for '{0}'.", actionName); + } + + return result; + } + private void SyncScope() { _androidJNI.AttachCurrentThread(); @@ -514,6 +815,11 @@ private void SyncScope() public void Close() { + if (_closed) + { + return; + } + _closed = true; _scopeSyncShutdownSource.Cancel(); diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs index 005aed753..70e736fc2 100644 --- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs +++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs @@ -35,25 +35,45 @@ public static void Configure(SentryUnityOptions options) Logger?.LogDebug("Checking whether the Android SDK is present."); + var isExternal = options.AndroidNativeInitializationType is NativeInitializationType.External; + // If it's not been set (in a test) SentryJava ??= new SentryJava(Logger); if (!SentryJava.IsSentryJavaPresent()) { - Logger?.LogError("Android Native Support has been enabled but the " + - "Android SDK is missing. This could have been caused by a mismatching" + - "build time / runtime configuration. Please make sure you have " + - "Android Native Support enabled during build time."); + if (isExternal) + { + Logger?.LogError("The Android SDK is missing. With external initialization, the app has to depend on " + + "'io.sentry:sentry-android'."); + } + else + { + Logger?.LogError("Android Native Support has been enabled but the " + + "Android SDK is missing. This could have been caused by a mismatching" + + "build time / runtime configuration. Please make sure you have " + + "Android Native Support enabled during build time."); + } return; } Logger?.LogDebug("Checking whether the Android SDK has already been initialized"); + var isAttached = false; + if (SentryJava.IsEnabled() is true) { Logger?.LogDebug("The Android SDK is already initialized"); + isAttached = isExternal; + } + else if (isExternal && SentryJava.IsMainProcess() is not false) + { + Logger?.LogWarning("The native SDK is expected to be initialized by the app but it is not. " + + "Make sure the app initializes Sentry before the game starts. Skipping native support."); + return; } else { + // With external initialization, the game runs in its own process, where the app has not initialized the SDK. Logger?.LogInfo("Initializing the Android SDK"); SentryJava.Init(options); @@ -70,11 +90,37 @@ public static void Configure(SentryUnityOptions options) Logger?.LogDebug("Configuring scope sync"); options.NativeContextWriter = new NativeContextWriter(SentryJava); - options.ScopeObserver = new AndroidJavaScopeObserver(options, SentryJava); + options.ScopeObserver = isExternal + ? new ExternalAndroidJavaScopeObserver(options, SentryJava, isAttached) + : new AndroidJavaScopeObserver(options, SentryJava); options.EnableScopeSync = true; options.NativeDebugImageProvider = new Native.NativeDebugImageProvider(); - if (options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) + if (isExternal) + { + // In its own process, the game's SDK took them from the app's SDK when it initialized. + options.UseAppReleaseAndEnvironment(() => SentryJava.GetOption("getRelease"), + () => SentryJava.GetOption("getDist"), () => SentryJava.GetOption("getEnvironment")); + + if (isAttached) + { + NativePropagationContext.Use(options, SentryJava.GetTraceHeader, SentryJava.GetBaggageHeader); + NativeAppUser.Use(options, SentryJava.GetUser); + } + else + { + UseMainProcessScope(options, SentryJava); + } + + if (options.AutoSessionTracking) + { + Logger?.LogDebug("Disabling session tracking - the app tracks sessions."); + options.AutoSessionTracking = false; + } + } + + // When attached, the app's native SDK decides on app hang tracking. + if (!isAttached && options.NativeAppHangTrackingEnabled && options.NdkIntegrationEnabled) { Logger?.LogDebug("Starting the app-hang heartbeat coroutine."); SentryMonoBehaviour.Instance.StartAppHangHeartbeat( @@ -119,7 +165,11 @@ public static void Configure(SentryUnityOptions options) e, "Failed to reinstall backend. Captured native crashes will miss scope data and tag."); } - options.NativeSupportCloseCallback = () => Close(options); + // When attached, the app owns the SDK's lifetime. + if (!isAttached) + { + options.NativeSupportCloseCallback += () => Close(options); + } Logger?.LogDebug("Fetching installation ID"); @@ -136,6 +186,37 @@ public static void Configure(SentryUnityOptions options) Logger?.LogInfo("Successfully configured the Android SDK"); } + // The game runs in its own process and continues the trace the app was in when it showed the game, with its user. + private static void UseMainProcessScope(SentryUnityOptions options, ISentryJava sentryJava) + { + string? traceHeader = null; + SentryUser? appUser = null; + + void Refresh() + { + // The app's SDK rewrites the file, so a read can come up empty. The previous trace still applies. + if (sentryJava.GetMainProcessTraceHeader() is { } refreshed && SentryTraceHeader.Parse(refreshed) is { } header) + { + Logger?.LogDebug("Continuing the app's trace: {0}", refreshed); + traceHeader = refreshed; + sentryJava.SetTrace(header.TraceId, header.SpanId); + } + + // A file that can't be read is being rewritten, so the previous user still applies. + if (sentryJava.TryGetMainProcessUser(out var user)) + { + appUser = user; + } + } + + Refresh(); + var monoBehaviour = SentryMonoBehaviour.Instance; + monoBehaviour.ApplicationResuming += Refresh; + options.NativeSupportCloseCallback += () => monoBehaviour.ApplicationResuming -= Refresh; + NativePropagationContext.Use(options, () => traceHeader, () => null); + NativeAppUser.Use(options, () => appUser); + } + /// /// Closes the native Android support. /// diff --git a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs index f2519ba9c..54b85b79c 100644 --- a/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs +++ b/src/Sentry.Unity.Editor/Android/AndroidManifestConfiguration.cs @@ -120,6 +120,13 @@ public void OnPostGenerateGradleAndroidProject(string basePath) /// private bool AndroidSdkEnabled => _androidSdkEnabled ??= EvaluateAndroidSdkEnabled(); + /// + /// With external initialization, the app that embeds the game (Unity as a Library) brings the Android SDK. + /// + private bool IsExternal => _options?.AndroidNativeInitializationType is NativeInitializationType.External; + + private bool ShipsAndroidSdk => AndroidSdkEnabled && !IsExternal; + private bool EvaluateAndroidSdkEnabled() { if (_options is null) @@ -155,6 +162,16 @@ internal void ModifyManifest(string basePath) var androidManifest = new AndroidManifest(manifestPath, _logger); androidManifest.RemovePreviousConfigurations(); + + if (IsExternal) + { + // The manifest merges into the app's. Its Sentry configuration is the app's. + _logger.LogDebug("Leaving the Android SDK's configuration to the app that embeds the game."); + _ = androidManifest.Save(); + + return; + } + androidManifest.AddDisclaimerComment(); if (!AndroidSdkEnabled) @@ -258,7 +275,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad var androidSdkPath = Path.Combine(unityProjectPath, "Packages", SentryPackageInfo.GetName(), "Plugins", "Android", "Sentry~"); var targetPath = Path.Combine(gradlePath, "unityLibrary", "libs"); - if (AndroidSdkEnabled) + if (ShipsAndroidSdk) { if (!Directory.Exists(androidSdkPath)) { @@ -301,7 +318,7 @@ internal void CopyAndroidSdkToGradleProject(string unityProjectPath, string grad internal void AddAndroidSdkDependencies(string gradleProjectPath) { var tool = new GradleSetup(_logger, gradleProjectPath); - var nativeSupportEnabled = AndroidSdkEnabled; + var nativeSupportEnabled = ShipsAndroidSdk; try { @@ -326,7 +343,7 @@ internal void SetupSymbolsUpload(string unityProjectPath, string gradleProjectPa var isExporting = EditorUserBuildSettings.exportAsGoogleAndroidProject; _logger.LogInfo("The project is exporting: '{0}'", isExporting); - var symbolsUpload = new DebugSymbolUpload(_logger, _sentryCliOptions, unityProjectPath, gradleProjectPath, isExporting, AndroidUtils.ShouldUploadMapping()); + var symbolsUpload = new DebugSymbolUpload(_logger, _sentryCliOptions, unityProjectPath, gradleProjectPath, isExporting, AndroidUtils.ShouldUploadMapping(), isExternal: IsExternal); if (_options is not { Enabled: true, AndroidNativeSupportEnabled: true }) { @@ -364,10 +381,10 @@ internal void SetupSymbolsUpload(string unityProjectPath, string gradleProjectPa // In practice, users should be able to build the project on any platform, regardless of where Unity // ran. In that case, we would either need to include all CLI binaries and switch in Gradle, or let // gradle download CLI on demand (relevant code could be taken from sentry-java repo?). - var launcherDirectory = Path.Combine(gradleProjectPath, "launcher"); - var sentryCliPath = SentryCli.SetupSentryCli(isExporting ? launcherDirectory : null); + var moduleDirectory = Path.Combine(gradleProjectPath, symbolsUpload.ModuleName); + var sentryCliPath = SentryCli.SetupSentryCli(isExporting ? moduleDirectory : null); - SentryCli.CreateSentryProperties(launcherDirectory, _sentryCliOptions!, _options!); + SentryCli.CreateSentryProperties(moduleDirectory, _sentryCliOptions!, _options!); symbolsUpload.TryCopySymbolsToGradleProject(); // We need to remove the old upload task first as the project persists across consecutive builds and the diff --git a/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs b/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs index ab6c41d67..16e50df0c 100644 --- a/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs +++ b/src/Sentry.Unity.Editor/Android/DebugSymbolUpload.cs @@ -26,6 +26,7 @@ internal class DebugSymbolUpload private readonly string _gradleScriptPath; private readonly bool _isExporting; private readonly bool _isMinifyEnabled; + private readonly bool _isExternal; private readonly SentryCliOptions? _cliOptions; private readonly List _symbolUploadPaths; @@ -77,10 +78,18 @@ private string SymbolUploadTaskFormat } stringBuilder.AppendLine(" }"); - stringBuilder.AppendLine(" tasks.named('assembleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); - stringBuilder.AppendLine(" tasks.named('assembleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); - stringBuilder.AppendLine(" tasks.named('bundleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); - stringBuilder.AppendLine(" tasks.named('bundleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); + if (_isExternal) + { + // The app builds the library but never assembles it. IL2CPP builds before the native libraries merge. + stringBuilder.AppendLine(" tasks.matching { it.name ==~ /merge\\w+JniLibFolders/ }.configureEach { finalizedBy 'sentryUploadSymbols' }"); + } + else + { + stringBuilder.AppendLine(" tasks.named('assembleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); + stringBuilder.AppendLine(" tasks.named('assembleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); + stringBuilder.AppendLine(" tasks.named('bundleDebug').configure { finalizedBy 'sentryUploadSymbols' }"); + stringBuilder.AppendLine(" tasks.named('bundleRelease').configure { finalizedBy 'sentryUploadSymbols' }"); + } stringBuilder.AppendLine("}"); return stringBuilder.ToString(); @@ -93,21 +102,27 @@ public DebugSymbolUpload(IDiagnosticLogger logger, string gradleProjectPath, bool isExporting = false, bool minifyEnabled = false, - IApplication? application = null) + IApplication? application = null, + bool isExternal = false) { _logger = logger; _unityProjectPath = unityProjectPath; _gradleProjectPath = gradleProjectPath; - _gradleScriptPath = Path.Combine(_gradleProjectPath, "launcher/build.gradle"); + _isExternal = isExternal; + _gradleScriptPath = Path.Combine(_gradleProjectPath, ModuleName, "build.gradle"); _isExporting = isExporting; - _isMinifyEnabled = minifyEnabled; + // The app minifies, so it uploads the mapping. + _isMinifyEnabled = minifyEnabled && !isExternal; _cliOptions = cliOptions; _symbolUploadPaths = GetSymbolUploadPaths(application); _mappingFilePath = GetMappingFilePath(application); } + // With external initialization, the app embeds 'unityLibrary' and never builds 'launcher'. + internal string ModuleName => _isExternal ? "unityLibrary" : "launcher"; + public void AppendUploadToGradleFile(string sentryCliPath) { _logger.LogInfo("Appending debug symbols upload task to gradle file."); @@ -131,9 +146,10 @@ public void AppendUploadToGradleFile(string sentryCliPath) if (_isExporting) { - _logger.LogInfo("Project is exporting. Setting upload-dif to 'project.rootDir'"); + _logger.LogInfo("Project is exporting. Setting upload-dif to the exported project."); - uploadDifArguments += ", project.rootDir"; + // The exported project is the root unless an app includes 'unityLibrary' in its own build. + uploadDifArguments += _isExternal ? ", project.projectDir.parentFile" : ", project.rootDir"; sentryCliPath = $"./{Path.GetFileName(sentryCliPath)}"; } else if (_symbolUploadPaths.Count > 0) @@ -168,7 +184,7 @@ public void AppendUploadToGradleFile(string sentryCliPath) symbolUploadText = symbolUploadText.Replace(UploadArgsMarker, uploadDifArguments); symbolUploadText = symbolUploadText.Replace(ProguardArgsMarker, uploadProguardArguments); - var gradleBuildFile = LoadGradleScript(); + var gradleBuildFile = LoadGradleScript(_gradleScriptPath); if (gradleBuildFile.Contains(SymbolUploadTaskStartComment) || gradleBuildFile.Contains("sentryUploadSymbols")) { throw new InvalidOperationException($"Failed to create Debug Symbol Upload Task. Task already exists in gradle file. A clean build should resolve this.{RaiseIssuePrompt}"); @@ -181,20 +197,33 @@ public void AppendUploadToGradleFile(string sentryCliPath) File.WriteAllText(_gradleScriptPath, stringBuilder.ToString()); } - private string LoadGradleScript() + private static string LoadGradleScript(string gradleScriptPath) { - if (!File.Exists(_gradleScriptPath)) + if (!File.Exists(gradleScriptPath)) { - throw new FileNotFoundException($"Failed to find the gradle config.{RaiseIssuePrompt}", _gradleScriptPath); + throw new FileNotFoundException($"Failed to find the gradle config.{RaiseIssuePrompt}", gradleScriptPath); } - return File.ReadAllText(_gradleScriptPath); + return File.ReadAllText(gradleScriptPath); } public void RemoveUploadFromGradleFile() { _logger.LogDebug("Attempting to remove the previous upload task from the gradle project."); - var gradleBuildFile = LoadGradleScript(); + // The task may have been added to the other module by a build with a different initialization type. + foreach (var moduleName in new[] { "launcher", "unityLibrary" }) + { + var gradleScriptPath = Path.Combine(_gradleProjectPath, moduleName, "build.gradle"); + if (gradleScriptPath == _gradleScriptPath || File.Exists(gradleScriptPath)) + { + RemoveUploadFromGradleFile(gradleScriptPath); + } + } + } + + private void RemoveUploadFromGradleFile(string gradleScriptPath) + { + var gradleBuildFile = LoadGradleScript(gradleScriptPath); if (!gradleBuildFile.Contains(SymbolUploadTaskStartComment)) { _logger.LogDebug("No previous upload task found."); @@ -212,7 +241,7 @@ public void RemoveUploadFromGradleFile() throw new InvalidOperationException($"Failed to remove Debug Symbol Upload Task from gradle file. A clean build should resolve this.{RaiseIssuePrompt}"); } - using var streamWriter = File.CreateText(_gradleScriptPath); + using var streamWriter = File.CreateText(gradleScriptPath); streamWriter.Write(gradleBuildFile); } diff --git a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs index 44e92135c..a7f0c542b 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.AppHang.cs @@ -15,6 +15,8 @@ public partial class SentryMonoBehaviour private Coroutine? _appHangHeartbeat; private Action? _appHangPause; + private Action? _appHangResume; + private bool _appHangArmed; /// /// Starts the app-hang heartbeat on the main thread at a fixed 1-second interval. Arming is @@ -31,8 +33,22 @@ internal Coroutine StartAppHangHeartbeat(Action heartbeat, Action pause) ApplicationPausing -= _appHangPause; } + if (_appHangResume is not null) + { + ApplicationResuming -= _appHangResume; + } + _appHangPause = pause; ApplicationPausing += pause; + // Re-arms right away instead of on the next tick, so a hang right after resuming is caught. + _appHangResume = () => + { + if (_appHangArmed) + { + heartbeat(); + } + }; + ApplicationResuming += _appHangResume; return StartAppHangHeartbeat(heartbeat, AppHangHeartbeatInterval); } @@ -44,6 +60,7 @@ internal Coroutine StartAppHangHeartbeat(Action heartbeat, TimeSpan interval) StopCoroutine(_appHangHeartbeat); } + _appHangArmed = false; _appHangHeartbeat = StartCoroutine(AppHangHeartbeatCoroutine(heartbeat, interval)); return _appHangHeartbeat; } @@ -57,12 +74,17 @@ private IEnumerator AppHangHeartbeatCoroutine(Action heartbeat, TimeSpan interva // This also works in batchmode/headless (e.g. LinuxServer), unlike WaitForEndOfFrame. yield return null; heartbeat(); + _appHangArmed = true; var wait = new WaitForSecondsRealtime((float)interval.TotalSeconds); while (true) { yield return wait; - heartbeat(); + // Unity still renders a few frames after losing focus, and a heartbeat there would re-arm the paused detector. + if (_isRunning) + { + heartbeat(); + } } } } diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 799c94fac..10ecce1cf 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -286,6 +286,8 @@ internal bool NativeAppHangTrackingEnabled /// When set to , the options are written and hardcoded into the /// Gradle project during the build process. This means that the options cannot be changed at runtime, as they are /// embedded into the project itself. + /// When set to , the app that embeds the game provides the native + /// SDK, e.g. when using Unity as a Library. This is experimental. /// public NativeInitializationType AndroidNativeInitializationType { get; set; } = NativeInitializationType.Runtime; @@ -784,8 +786,10 @@ public enum NativeInitializationType /// The app that embeds the game owns the native SDK, e.g. when using Unity as a Library. The SDK does not initialize /// or close the native SDK. It attaches to it if the app has initialized it before the game starts. Options that /// you set in Unity do not apply to the native SDK. Events from the game use the app's release, environment, and - /// trace, unless the release and environment are set in the options. Currently supported on iOS. Experimental: the - /// behavior may change between releases. + /// trace, unless the release and environment are set in the options. Currently supported on Android and iOS. + /// Experimental: the behavior may change between releases. + /// On Android, when the game runs in its own process, where the app did not initialize the native SDK, the SDK + /// initializes it there with the options set in Unity and owns it in that process. /// External, } diff --git a/test/Scripts.Tests/test-plugin-platforms.ps1 b/test/Scripts.Tests/test-plugin-platforms.ps1 index 9604f9d57..db7ca9ef9 100644 --- a/test/Scripts.Tests/test-plugin-platforms.ps1 +++ b/test/Scripts.Tests/test-plugin-platforms.ps1 @@ -71,9 +71,10 @@ $AliasedDependencyScopes = @( ) # How package-dev deviates. The dev package keeps the test assemblies, which scripts/pack.ps1 excludes -# from the release, and the iOS bridge stays editor-loadable for the editor-only -# Sentry.Unity.iOS.Tests assembly that references it. +# from the release, and the Android and iOS integrations stay editor-loadable for the editor-only +# Sentry.Unity.Android.Tests and Sentry.Unity.iOS.Tests assemblies that reference them. $DevOnlyPluginScopes = @{ + "Runtime/Sentry.Unity.Android.dll" = "Android, Editor" "Runtime/Sentry.Unity.iOS.dll" = "Editor, iOS, OSXUniversal" "Tests/Editor/Sentry.Unity.Editor.Tests.dll" = "Editor" "Tests/Editor/Sentry.Unity.Editor.iOS.Tests.dll" = "Editor" @@ -85,6 +86,7 @@ $DevOnlyPluginScopes = @{ # Files the package directory is allowed to override in the release, each validated against the # release table above. $ExpectedReleaseOverrides = @( + "Runtime/Sentry.Unity.Android.dll" "Runtime/Sentry.Unity.iOS.dll" ) @@ -308,7 +310,7 @@ else { } } - # package-dev carries the test assemblies and the editor-loadable iOS bridge. + # package-dev carries the test assemblies and the editor-loadable Android and iOS integrations. $devScopes = @{} foreach ($pair in $ExpectedPluginScopes.GetEnumerator()) { $devScopes[$pair.Key] = $pair.Value } foreach ($pair in $DevOnlyPluginScopes.GetEnumerator()) { $devScopes[$pair.Key] = $pair.Value } diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs index db5b37d7c..7cdbc94a9 100644 --- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs +++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs @@ -231,4 +231,154 @@ public void Configure_NativeInitFails_LogsErrorAndReturns() Assert.Null(options.ScopeObserver); } + [Test] + public void Configure_ExternalAppInitialized_AttachesWithoutOwningIdentity() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = true; + + SentryNativeAndroid.Configure(options); + + Assert.False(_testSentryJava.InitCalled); + Assert.IsInstanceOf(options.ScopeObserver); + Assert.IsNull(options.NativeSupportCloseCallback); + Assert.False(options.AutoSessionTracking); + } + + [Test] + public void Configure_ExternalNotInitializedInMainProcess_SkipsNativeSupport() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = true; + + SentryNativeAndroid.Configure(options); + + Assert.False(_testSentryJava.InitCalled); + Assert.Null(options.ScopeObserver); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_InitializesAndOwnsSdk() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + + SentryNativeAndroid.Configure(options); + + Assert.True(_testSentryJava.InitCalled); + Assert.IsInstanceOf(options.ScopeObserver); + Assert.NotNull(options.NativeSupportCloseCallback); + Assert.False(options.AutoSessionTracking); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_ContinuesMainProcessTrace() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessTraceHeader = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000"; + + SentryNativeAndroid.Configure(options); + + Assert.AreEqual("75302ac48a024bde9a3b3734a82e36c8", _testSentryJava.Trace?.TraceId.ToString()); + Assert.AreEqual("1000000000000000", _testSentryJava.Trace?.SpanId.ToString()); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_StopsRefreshingTraceOnClose() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessTraceHeader = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000"; + + SentryNativeAndroid.Configure(options); + options.NativeSupportCloseCallback?.Invoke(); + _testSentryJava.MainProcessTraceHeader = "b6ee9f3a1c0d4e2f8a7b6c5d4e3f2a1b-2000000000000000"; + SentryMonoBehaviour.Instance.UpdatePauseStatus(true); + SentryMonoBehaviour.Instance.UpdatePauseStatus(false); + + Assert.AreEqual("75302ac48a024bde9a3b3734a82e36c8", _testSentryJava.Trace?.TraceId.ToString()); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_KeepsUserWhenReadFailsOnResume() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessUser = new SentryUser { Id = "app-user" }; + + SentryNativeAndroid.Configure(options); + _testSentryJava.MainProcessUserReadable = false; + SentryMonoBehaviour.Instance.UpdatePauseStatus(true); + SentryMonoBehaviour.Instance.UpdatePauseStatus(false); + + Assert.AreEqual("app-user", ProcessEvent(options).User.Id); + } + + [Test] + public void Configure_ExternalNotInitializedInGameProcess_DropsUserWhenAppLoggedOut() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Enabled = false; + _testSentryJava.MainProcess = false; + _testSentryJava.MainProcessUser = new SentryUser { Id = "app-user" }; + + SentryNativeAndroid.Configure(options); + _testSentryJava.MainProcessUser = null; + SentryMonoBehaviour.Instance.UpdatePauseStatus(true); + SentryMonoBehaviour.Instance.UpdatePauseStatus(false); + + Assert.AreNotEqual("app-user", ProcessEvent(options).User.Id); + } + + private static SentryEvent ProcessEvent(SentryUnityOptions options) => + options.GetAllEventProcessors().OfType().Single().Process(new SentryEvent { User = new SentryUser() }); + + [Test] + public void Configure_External_UsesAppReleaseAndEnvironment() + { + var options = new SentryUnityOptions { AndroidNativeInitializationType = NativeInitializationType.External }; + _testSentryJava.Options["getRelease"] = "io.sentry.host@1.0+1"; + _testSentryJava.Options["getEnvironment"] = "staging"; + + SentryNativeAndroid.Configure(options); + + Assert.AreEqual("io.sentry.host@1.0+1", options.Release); + Assert.AreEqual("staging", options.Environment); + } + + [Test] + public void Configure_ExternalReleaseAndEnvironmentSet_KeepsThem() + { + var options = new SentryUnityOptions + { + AndroidNativeInitializationType = NativeInitializationType.External, + Release = "game@2.0", + Environment = "game-env" + }; + _testSentryJava.Options["getRelease"] = "io.sentry.host@1.0+1"; + _testSentryJava.Options["getEnvironment"] = "staging"; + + SentryNativeAndroid.Configure(options); + + Assert.AreEqual("game@2.0", options.Release); + Assert.AreEqual("game-env", options.Environment); + } + + [Test] + public void Configure_RuntimeAlreadyInitialized_KeepsOwnership() + { + var options = new SentryUnityOptions(); + _testSentryJava.Enabled = true; + + SentryNativeAndroid.Configure(options); + + Assert.IsNotInstanceOf(options.ScopeObserver); + Assert.NotNull(options.NativeSupportCloseCallback); + } } diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs index 84c7f3735..32c2d6ba4 100644 --- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs +++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Sentry.Extensibility; namespace Sentry.Unity.Android.Tests; @@ -10,16 +11,49 @@ internal class TestSentryJava : ISentryJava public bool SentryPresent { get; set; } = true; public string? InstallationId { get; set; } public bool? IsCrashedLastRun { get; set; } + public bool? MainProcess { get; set; } = true; + public bool InitCalled { get; private set; } + public bool CloseCalled { get; private set; } + public Dictionary Options { get; } = new(); + public string? TraceHeader { get; set; } + public string? BaggageHeader { get; set; } + public string? MainProcessTraceHeader { get; set; } + public SentryUser? User { get; set; } + public SentryUser? MainProcessUser { get; set; } + public bool MainProcessUserReadable { get; set; } = true; + public (SentryId TraceId, SpanId SpanId)? Trace { get; private set; } public bool? IsEnabled() => Enabled; - public void Init(SentryUnityOptions options) { } + public bool? IsMainProcess() => MainProcess; + + public void Init(SentryUnityOptions options) + { + InitCalled = true; + Enabled = InitSuccessful; + } + + public string? GetOption(string getter) => Options.TryGetValue(getter, out var value) ? value : null; + + public string? GetTraceHeader() => TraceHeader; + + public string? GetBaggageHeader() => BaggageHeader; + + public string? GetMainProcessTraceHeader() => MainProcessTraceHeader; + + public SentryUser? GetUser() => User; + + public bool TryGetMainProcessUser(out SentryUser? user) + { + user = MainProcessUserReadable ? MainProcessUser : null; + return MainProcessUserReadable; + } public string? GetInstallationId() => InstallationId; public bool? CrashedLastRun() => IsCrashedLastRun; - public void Close() { } + public void Close() => CloseCalled = true; public void WriteScope( string? AppStartTime, @@ -53,7 +87,7 @@ public void SetUser(SentryUser user) { } public void UnsetUser() { } - public void SetTrace(SentryId traceId, SpanId spanId) { } + public void SetTrace(SentryId traceId, SpanId spanId) => Trace = (traceId, spanId); public void SetEnvironment(string? environment) { } diff --git a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs index 6fb65b643..cb7192c9b 100644 --- a/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs +++ b/test/Sentry.Unity.Editor.Tests/Android/AndroidManifestConfigurationTests.cs @@ -170,6 +170,37 @@ public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeRunt StringAssert.Contains("", manifest); } + [Test] + public void ModifyManifest_InitTypeExternal_DoesNotAddSentry() + { + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + var manifest = WithAndroidManifest(basePath => sut.ModifyManifest(basePath)); + + StringAssert.DoesNotContain("io.sentry.auto-init", manifest); + StringAssert.DoesNotContain("io.sentry.dsn", manifest); + } + + [Test] + public void CopyAndroidSdkToGradleProject_InitTypeExternal_RemovesAndroidSdkFromGradleProject() + { + var fakeProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var unityProjectPath = Path.Combine(fakeProjectPath, "UnityProject"); + var gradleProjectPath = Path.Combine(fakeProjectPath, "GradleProject"); + DebugSymbolUploadTests.SetupFakeProject(fakeProjectPath); + var androidSdk = Path.Combine(gradleProjectPath, "unityLibrary", "libs", "androidSdk.jar"); + File.Create(androidSdk).Close(); + + _fixture.SentryUnityOptions!.AndroidNativeInitializationType = NativeInitializationType.External; + var sut = _fixture.GetSut(); + + sut.CopyAndroidSdkToGradleProject(unityProjectPath, gradleProjectPath); + + Assert.IsFalse(File.Exists(androidSdk)); + + Directory.Delete(fakeProjectPath, true); + } + [Test] public void ModifyManifest_UnityOptions_AndroidNativeSupportEnabled_InitTypeBuildTime_AddsSentryAndAutoInits() { diff --git a/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs b/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs index f863af8c7..2341cbac8 100644 --- a/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs +++ b/test/Sentry.Unity.Editor.Tests/Android/DebugSymbolUploadTests.cs @@ -23,6 +23,7 @@ public class Fixture public bool IsExporting { get; set; } public bool IsMinifyEnabled { get; set; } public bool IgnoreCliErrors { get; set; } + public bool IsExternal { get; set; } public TestApplication Application { get; set; } public Fixture() @@ -45,7 +46,8 @@ public Fixture() GradleProjectPath, IsExporting, IsMinifyEnabled, - Application + Application, + IsExternal ); } @@ -195,6 +197,38 @@ public void AppendUploadToGradleFile_AllRequirementsMet_AppendsUploadTask(bool i } } + [Test] + public void AppendUploadToGradleFile_External_AppendsUploadTaskToUnityLibrary() + { + _fixture.IsExporting = true; + _fixture.IsMinifyEnabled = true; + _fixture.IsExternal = true; + var sut = _fixture.GetSut(); + + sut.AppendUploadToGradleFile(_fixture.SentryCliPath); + + var actualFileContent = File.ReadAllText(GetGradleFilePath("unityLibrary")); + StringAssert.Contains("project.projectDir.parentFile", actualFileContent); + StringAssert.Contains("tasks.matching { it.name ==~ /merge\\w+JniLibFolders/ }.configureEach { finalizedBy 'sentryUploadSymbols' }", actualFileContent); + StringAssert.DoesNotContain("assembleRelease", actualFileContent); + StringAssert.DoesNotContain("upload-proguard", actualFileContent); + StringAssert.DoesNotContain("sentryUploadSymbols", File.ReadAllText(GetGradleFilePath())); + } + + [Test] + public void RemoveUploadTaskFromGradleFile_InitializationTypeChanged_RemovesUploadTaskFromBothModules() + { + _fixture.GetSut().AppendUploadToGradleFile(_fixture.SentryCliPath); + _fixture.IsExternal = true; + var sut = _fixture.GetSut(); + sut.AppendUploadToGradleFile(_fixture.SentryCliPath); + + sut.RemoveUploadFromGradleFile(); + + StringAssert.DoesNotContain("sentryUploadSymbols", File.ReadAllText(GetGradleFilePath())); + StringAssert.DoesNotContain("sentryUploadSymbols", File.ReadAllText(GetGradleFilePath("unityLibrary"))); + } + [Test] public void RemoveUploadTaskFromGradleFile_GradleFileDoesNotExist_ThrowsFileNotFoundException() { @@ -293,5 +327,5 @@ public static void SetupFakeProject(string fakeProjectPath) } } - private string GetGradleFilePath() => Path.Combine(_fixture.GradleProjectPath, "launcher/build.gradle"); + private string GetGradleFilePath(string moduleName = "launcher") => Path.Combine(_fixture.GradleProjectPath, moduleName, "build.gradle"); } diff --git a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs index e8690ce88..4b0edf7f3 100644 --- a/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs +++ b/test/Sentry.Unity.Tests/SentryMonoBehaviourAppHangTests.cs @@ -59,6 +59,54 @@ public IEnumerator StartAppHangHeartbeat_StopsWhenObjectDestroyed() Assert.AreEqual(countAfterDestroy, count); } + [UnityTest] + public IEnumerator StartAppHangHeartbeat_SkipsHeartbeatsWhilePaused() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, TimeSpan.FromSeconds(0.05)); + yield return null; + + sut.UpdatePauseStatus(true); + var countWhilePaused = count; + yield return new WaitForSecondsRealtime(0.12f); + Assert.AreEqual(countWhilePaused, count); + + sut.UpdatePauseStatus(false); + yield return new WaitForSecondsRealtime(0.12f); + Assert.Greater(count, countWhilePaused); + } + + [UnityTest] + public IEnumerator StartAppHangHeartbeat_BeatsRightAwayOnResume() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, () => { }); + yield return null; + + sut.UpdatePauseStatus(true); + var countWhilePaused = count; + sut.UpdatePauseStatus(false); + + Assert.AreEqual(countWhilePaused + 1, count); + } + + [Test] + public void StartAppHangHeartbeat_DoesNotArmOnResumeBeforeFirstHeartbeat() + { + var sut = GetSut(); + var count = 0; + + sut.StartAppHangHeartbeat(() => count++, () => { }); + sut.UpdatePauseStatus(true); + sut.UpdatePauseStatus(false); + + Assert.AreEqual(0, count); + } + [Test] public void StartAppHangHeartbeat_PausesNativeDetectorWhenApplicationPauses() {