Skip to content

Commit

Permalink
feat: Managed layer initializes native SDK on Android (#1924)
Browse files Browse the repository at this point in the history
  • Loading branch information
bitsandfoxes authored Dec 13, 2024
1 parent daae78d commit c0681ff
Show file tree
Hide file tree
Showing 13 changed files with 471 additions and 71 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@

### API Changes

- The native layer on iOS no longer self-initializes before the Unity game starts. Instead, it accepts the options at the end of the configure call. To restore the old behavior, users can opt-in to initializing native first via `IosInitializeNativeFirst`. Note that using this option comes with the limitation of baking the options into the generated Xcode project at build-time. ([#1915](https://github.com/getsentry/sentry-unity/pull/1915))
- The native layer on mobile platforms (iOS and Android) no longer self-initializes before the Unity game starts. Previously, the SDK would use the options at build-time and bake them into the native layer. Instead, the SDK will now take the options passed into the `Configure` callback and use those to initialize the native SDKs. This allows users to modify the native SDK's options at runtime programmatically.
The initialization behaviour is controlled by `IosNativeInitializationType` and `AndroidNativeInitializationType` options. These can be set from `Runtime` (default) to `BuildTime` to restore the previous flow and bake the options into the native projects. ([#1915](https://github.com/getsentry/sentry-unity/pull/1915), [#1924](https://github.com/getsentry/sentry-unity/pull/1924))

### Fixes

- On Android, the SDK not longer freezes the game when failing to sync with the native SDK ([#1927](https://github.com/getsentry/sentry-unity/pull/1927))

### Dependencies

Expand Down
4 changes: 2 additions & 2 deletions src/Sentry.Unity.Android/IJniExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ namespace Sentry.Unity.Android;

internal interface IJniExecutor : IDisposable
{
public TResult? Run<TResult>(Func<TResult?> jniOperation);
public void Run(Action jniOperation);
public TResult? Run<TResult>(Func<TResult?> jniOperation, TimeSpan? timeout = null);
public void Run(Action jniOperation, TimeSpan? timeout = null);
}
89 changes: 74 additions & 15 deletions src/Sentry.Unity.Android/JniExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Sentry.Extensibility;
using UnityEngine;

namespace Sentry.Unity.Android;

internal class JniExecutor : IJniExecutor
{
// We're capping out at 16ms - 1 frame at 60 frames per second
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(16);

private readonly CancellationTokenSource _shutdownSource;
private readonly AutoResetEvent _taskEvent;
private readonly IDiagnosticLogger? _logger;

private Delegate _currentTask = null!; // The current task will always be set together with the task event

private TaskCompletionSource<object?>? _taskCompletionSource;

private readonly object _lock = new object();

public JniExecutor()
private bool _isDisposed;
private Thread? _workerThread;

public JniExecutor(IDiagnosticLogger? logger)
{
_logger = logger;
_taskEvent = new AutoResetEvent(false);
_shutdownSource = new CancellationTokenSource();

new Thread(DoWork) { IsBackground = true, Name = "SentryJniExecutorThread" }.Start();
_workerThread = new Thread(DoWork) { IsBackground = true, Name = "SentryJniExecutorThread" };
_workerThread.Start();
}

private void DoWork()
Expand All @@ -29,7 +40,7 @@ private void DoWork()

var waitHandles = new[] { _taskEvent, _shutdownSource.Token.WaitHandle };

while (true)
while (!_isDisposed)
{
var index = WaitHandle.WaitAny(waitHandles);
if (index > 0)
Expand All @@ -50,74 +61,122 @@ private void DoWork()
_taskCompletionSource?.SetResult(null);
break;
}
case Func<bool?> func1:
case Func<bool> func1:
{
var result = func1.Invoke();
_taskCompletionSource?.SetResult(result);
break;
}
case Func<string?> func2:
case Func<bool?> func2:
{
var result = func2.Invoke();
_taskCompletionSource?.SetResult(result);
break;
}
case Func<string?> func3:
{
var result = func3.Invoke();
_taskCompletionSource?.SetResult(result);
break;
}
default:
throw new ArgumentException("Invalid type for _currentTask.");
throw new NotImplementedException($"Task type '{_currentTask?.GetType()}' with value '{_currentTask}' is not implemented in the JniExecutor.");
}
}
catch (Exception e)
{
Debug.unityLogger.Log(LogType.Exception, UnityLogger.LogTag, $"Error during JNI execution: {e}");
_logger?.LogError(e, "Error during JNI execution.");
_taskCompletionSource?.SetException(e);
}
}

AndroidJNI.DetachCurrentThread();
}

public TResult? Run<TResult>(Func<TResult?> jniOperation)
public TResult? Run<TResult>(Func<TResult?> jniOperation, TimeSpan? timeout = null)
{
lock (_lock)
{
timeout ??= DefaultTimeout;
using var timeoutCts = new CancellationTokenSource(timeout.Value);
_taskCompletionSource = new TaskCompletionSource<object?>();
_currentTask = jniOperation;
_taskEvent.Set();

try
{
return (TResult?)_taskCompletionSource.Task.GetAwaiter().GetResult();
_taskCompletionSource.Task.Wait(timeoutCts.Token);
return (TResult?)_taskCompletionSource.Task.Result;
}
catch (OperationCanceledException)
{
_logger?.LogError("JNI execution timed out.");
return default;
}
catch (Exception e)
{
Debug.unityLogger.Log(LogType.Exception, UnityLogger.LogTag, $"Error during JNI execution: {e}");
_logger?.LogError(e, "Error during JNI execution.");
return default;
}
finally
{
_currentTask = null!;
}

return default;
}
}

public void Run(Action jniOperation)
public void Run(Action jniOperation, TimeSpan? timeout = null)
{
lock (_lock)
{
timeout ??= DefaultTimeout;
using var timeoutCts = new CancellationTokenSource(timeout.Value);
_taskCompletionSource = new TaskCompletionSource<object?>();
_currentTask = jniOperation;
_taskEvent.Set();

try
{
_taskCompletionSource.Task.Wait();
_taskCompletionSource.Task.Wait(timeoutCts.Token);
}
catch (OperationCanceledException)
{
_logger?.LogError("JNI execution timed out.");
}
catch (Exception e)
{
Debug.unityLogger.Log(LogType.Exception, UnityLogger.LogTag, $"Error during JNI execution: {e}");
_logger?.LogError(e, "Error during JNI execution.");
}
finally
{
_currentTask = null!;
}
}
}

public void Dispose()
{
if (_isDisposed)
{
return;
}

_isDisposed = true;

_shutdownSource.Cancel();
try
{
_workerThread?.Join(100);
}
catch (ThreadStateException)
{
_logger?.LogError("JNI Executor Worker thread was never started during disposal");
}
catch (ThreadInterruptedException)
{
_logger?.LogError("JNI Executor Worker thread was interrupted during disposal");
}

_taskEvent.Dispose();
}
}
105 changes: 105 additions & 0 deletions src/Sentry.Unity.Android/SentryJava.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
using System;
using System.Diagnostics;
using Sentry.Extensibility;
using UnityEngine;
using Debug = UnityEngine.Debug;

namespace Sentry.Unity.Android;

internal interface ISentryJava
{
public bool IsEnabled(IJniExecutor jniExecutor);
public bool Init(IJniExecutor jniExecutor, SentryUnityOptions options, TimeSpan timeout);
public string? GetInstallationId(IJniExecutor jniExecutor);
public bool? CrashedLastRun(IJniExecutor jniExecutor);
public void Close(IJniExecutor jniExecutor);
Expand Down Expand Up @@ -40,6 +45,93 @@ internal class SentryJava : ISentryJava
{
private static AndroidJavaObject GetSentryJava() => new AndroidJavaClass("io.sentry.Sentry");

public bool IsEnabled(IJniExecutor jniExecutor)
{
return jniExecutor.Run(() =>
{
using var sentry = GetSentryJava();
return sentry.CallStatic<bool>("isEnabled");
});
}

public bool Init(IJniExecutor jniExecutor, SentryUnityOptions options, TimeSpan timeout)
{
jniExecutor.Run(() =>
{
using var sentry = new AndroidJavaClass("io.sentry.android.core.SentryAndroid");
using var context = new AndroidJavaClass("com.unity3d.player.UnityPlayer")
.GetStatic<AndroidJavaObject>("currentActivity");

sentry.CallStatic("init", context, new AndroidOptionsConfiguration(androidOptions =>
{
androidOptions.Call("setDsn", options.Dsn);
androidOptions.Call("setDebug", options.Debug);
androidOptions.Call("setRelease", options.Release);
androidOptions.Call("setEnvironment", options.Environment);

var sentryLevelClass = new AndroidJavaClass("io.sentry.SentryLevel");
var levelString = GetLevelString(options.DiagnosticLevel);
var sentryLevel = sentryLevelClass.GetStatic<AndroidJavaObject>(levelString);
androidOptions.Call("setDiagnosticLevel", sentryLevel);

if (options.SampleRate.HasValue)
{
androidOptions.SetIfNotNull("setSampleRate", options.SampleRate.Value);
}

androidOptions.Call("setMaxBreadcrumbs", options.MaxBreadcrumbs);
androidOptions.Call("setMaxCacheItems", options.MaxCacheItems);
androidOptions.Call("setSendDefaultPii", options.SendDefaultPii);
androidOptions.Call("setEnableNdk", options.NdkIntegrationEnabled);
androidOptions.Call("setEnableScopeSync", options.NdkScopeSyncEnabled);

// Options that are not to be set by the user
// We're disabling some integrations as to not duplicate event or because the SDK relies on the .NET SDK
// implementation of certain feature - i.e. Session Tracking

// Note: doesn't work - produces a blank (white) screenshot
androidOptions.Call("setAttachScreenshot", false);
androidOptions.Call("setEnableAutoSessionTracking", false);
androidOptions.Call("setEnableActivityLifecycleBreadcrumbs", false);
androidOptions.Call("setAnrEnabled", false);
androidOptions.Call("setEnableScopePersistence", false);
}, options.DiagnosticLogger));
}, timeout);

return IsEnabled(jniExecutor);
}

internal class AndroidOptionsConfiguration : AndroidJavaProxy
{
private readonly Action<AndroidJavaObject> _callback;
private readonly IDiagnosticLogger? _logger;

public AndroidOptionsConfiguration(Action<AndroidJavaObject> callback, IDiagnosticLogger? logger)
: base("io.sentry.Sentry$OptionsConfiguration")
{
_callback = callback;
_logger = logger;
}

public override AndroidJavaObject? Invoke(string methodName, AndroidJavaObject[] args)
{
try
{
if (methodName != "configure" || args.Length != 1)
{
throw new Exception($"Invalid invocation: {methodName}({args.Length} args)");
}

_callback(args[0]);
}
catch (Exception e)
{
_logger?.LogError(e, "Error invoking {0} ’.", methodName);
}
return null;
}
}

public string? GetInstallationId(IJniExecutor jniExecutor)
{
return jniExecutor.Run(() =>
Expand Down Expand Up @@ -165,6 +257,17 @@ public ScopeCallback(Action<AndroidJavaObject> callback) : base("io.sentry.Scope
return null;
}
}

// https://github.com/getsentry/sentry-java/blob/db4dfc92f202b1cefc48d019fdabe24d487db923/sentry/src/main/java/io/sentry/SentryLevel.java#L4-L9
internal static string GetLevelString(SentryLevel level) => level switch
{
SentryLevel.Debug => "DEBUG",
SentryLevel.Error => "ERROR",
SentryLevel.Fatal => "FATAL",
SentryLevel.Info => "INFO",
SentryLevel.Warning => "WARNING",
_ => "DEBUG"
};
}

internal static class AndroidJavaObjectExtension
Expand All @@ -186,6 +289,8 @@ public static void SetIfNotNull<T>(this AndroidJavaObject javaObject, string pro
}
public static void SetIfNotNull(this AndroidJavaObject javaObject, string property, int? value) =>
SetIfNotNull(javaObject, property, value, "java.lang.Integer");
public static void SetIfNotNull(this AndroidJavaObject javaObject, string property, bool value) =>
SetIfNotNull(javaObject, property, value, "java.lang.Boolean");
public static void SetIfNotNull(this AndroidJavaObject javaObject, string property, bool? value) =>
SetIfNotNull(javaObject, property, value, "java.lang.Boolean");
}
17 changes: 15 additions & 2 deletions src/Sentry.Unity.Android/SentryNativeAndroid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,18 @@ public static void Configure(SentryUnityOptions options, ISentryUnityInfo sentry
return;
}

JniExecutor ??= new JniExecutor();
JniExecutor ??= new JniExecutor(options.DiagnosticLogger);

if (SentryJava.IsEnabled(JniExecutor))
{
options.DiagnosticLogger?.LogDebug("The Android SDK is already initialized");
}
// Local testing had Init at an average of about 25ms.
else if (!SentryJava.Init(JniExecutor, options, TimeSpan.FromMilliseconds(200)))
{
options.DiagnosticLogger?.LogError("Failed to initialize Android Native Support");
return;
}

options.NativeContextWriter = new NativeContextWriter(JniExecutor, SentryJava);
options.ScopeObserver = new AndroidJavaScopeObserver(options, JniExecutor);
Expand Down Expand Up @@ -98,6 +109,8 @@ public static void Configure(SentryUnityOptions options, ISentryUnityInfo sentry
options.DiagnosticLogger?.LogDebug("Failed to create new 'Default User ID'.");
}
}

options.DiagnosticLogger?.LogInfo("Successfully configured the Android SDK");
}

/// <summary>
Expand All @@ -119,7 +132,7 @@ internal static void Close(SentryUnityOptions options, ISentryUnityInfo sentryUn

// This is an edge-case where the Android SDK has been enabled and setup during build-time but is being
// shut down at runtime. In this case Configure() has not been called and there is no JniExecutor yet
JniExecutor ??= new JniExecutor();
JniExecutor ??= new JniExecutor(options.DiagnosticLogger);
SentryJava?.Close(JniExecutor);
JniExecutor.Dispose();
}
Expand Down
Loading

0 comments on commit c0681ff

Please sign in to comment.