diff --git a/CLAUDE.md b/CLAUDE.md
index f68c3912a..36d356257 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -156,7 +156,7 @@ Key targets defined in `Directory.Build.targets`:
| `BuildAndroidSDK` | Builds Android SDK via Gradle |
| `BuildLinuxSDK` | Builds Linux SDK via CMake |
| `BuildWindowsSDK` | Builds Windows SDK via CMake (Crashpad) |
-| `BuildCocoaSDK` | Downloads iOS/macOS SDKs from releases |
+| `BuildCocoaSDK` | Builds iOS/macOS SDKs via Xcode |
| `UnityEditModeTest` | Runs edit-mode unit tests |
| `UnityPlayModeTest` | Runs play-mode tests |
@@ -320,6 +320,18 @@ modules/
└── sentry-cocoa/ # iOS/macOS (prebuilt XCFramework)
```
+### Local Android NDK Development
+
+When iterating on `modules/sentry-native/ndk` together with `modules/sentry-java`, publish the local NDK build to `~/.m2` so sentry-java picks it up instead of mavenCentral:
+
+```bash
+pwsh scripts/build-native-ndk-local.ps1 # publish only
+pwsh scripts/build-native-ndk-local.ps1 -BuildJava # publish + rebuild :sentry-android-ndk
+pwsh scripts/build-native-ndk-local.ps1 -PurgeCache -BuildJava # first switch from central, or after stale builds
+```
+
+Prerequisite: `mavenLocal()` must precede `mavenCentral()` in `modules/sentry-java/settings.gradle.kts` (`dependencyResolutionManagement` block). The script aborts otherwise.
+
### Key Source Files
**Android (`src/Sentry.Unity.Android/`):**
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 8f6613858..49fe224c5 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -117,33 +117,57 @@
-
-
+
-
+
+ -Clean
+
+
+
-
+
+
+
+
+
+
+
+
+
+
$([System.IO.File]::ReadAllText("$(RepoRoot)modules/sentry-java/gradle/libs.versions.toml"))
$([System.Text.RegularExpressions.Regex]::Match($(PropertiesContent), 'sentry-native-ndk\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"').Groups[1].Value)
-
+
-
+
-
+
-
+
+
+
+
+
+ -PurgeCache
+
+
+
+
+
+
+
+
+ And ('$(RebuildNativeSdk)' == 'true' Or !Exists('$(SentryWindowsArtifactsDestination)sentry.dll'))" BeforeTargets="BeforeBuild">
@@ -222,10 +286,15 @@
-
+
+ And ('$(RebuildNativeSdk)' == 'true' Or !Exists('$(SentryLinuxArtifactsDestination)libsentry.so'))" BeforeTargets="BeforeBuild">
diff --git a/modules/sentry-java b/modules/sentry-java
index 6219eb3d8..d738e64ab 160000
--- a/modules/sentry-java
+++ b/modules/sentry-java
@@ -1 +1 @@
-Subproject commit 6219eb3d898ce527b1024eaa75e6a3ee5e985601
+Subproject commit d738e64ab248b7397f4fe113d60d66c7e28de5dc
diff --git a/modules/sentry-native b/modules/sentry-native
index c0e5f0705..96bc21228 160000
--- a/modules/sentry-native
+++ b/modules/sentry-native
@@ -1 +1 @@
-Subproject commit c0e5f0705da3853ff548c7ece77d639a20e1d8f5
+Subproject commit 96bc212285a79b08b8035ab5bc7a6ca5d99b2306
diff --git a/samples/unity-of-bugs/Assets/Scenes/3_AdditionalSamples.unity b/samples/unity-of-bugs/Assets/Scenes/3_AdditionalSamples.unity
index 85086bcb6..6d7db894a 100644
--- a/samples/unity-of-bugs/Assets/Scenes/3_AdditionalSamples.unity
+++ b/samples/unity-of-bugs/Assets/Scenes/3_AdditionalSamples.unity
@@ -13,7 +13,7 @@ OcclusionCullingSettings:
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
- serializedVersion: 9
+ serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
@@ -42,8 +42,8 @@ RenderSettings:
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
- serializedVersion: 12
- m_GIWorkflowMode: 1
+ serializedVersion: 13
+ m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
@@ -66,9 +66,6 @@ LightmapSettings:
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
- m_FinalGather: 0
- m_FinalGatherFiltering: 1
- m_FinalGatherRayCount: 256
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 0
@@ -103,7 +100,7 @@ NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
- serializedVersion: 2
+ serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
@@ -116,12 +113,145 @@ NavMeshSettings:
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
- accuratePlacement: 0
+ buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
+--- !u!1 &191506112
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 191506113}
+ - component: {fileID: 191506116}
+ - component: {fileID: 191506115}
+ - component: {fileID: 191506114}
+ m_Layer: 5
+ m_Name: Application-Not-Responding (1)
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &191506113
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 191506112}
+ m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 1603559786}
+ m_Father: {fileID: 253040315}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 0, y: 0}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 200, y: 30}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &191506114
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 191506112}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Navigation:
+ m_Mode: 3
+ m_WrapAround: 0
+ m_SelectOnUp: {fileID: 0}
+ m_SelectOnDown: {fileID: 0}
+ m_SelectOnLeft: {fileID: 0}
+ m_SelectOnRight: {fileID: 0}
+ m_Transition: 1
+ m_Colors:
+ m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
+ m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
+ m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
+ m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
+ m_ColorMultiplier: 1
+ m_FadeDuration: 0.1
+ m_SpriteState:
+ m_HighlightedSprite: {fileID: 0}
+ m_PressedSprite: {fileID: 0}
+ m_SelectedSprite: {fileID: 0}
+ m_DisabledSprite: {fileID: 0}
+ m_AnimationTriggers:
+ m_NormalTrigger: Normal
+ m_HighlightedTrigger: Highlighted
+ m_PressedTrigger: Pressed
+ m_SelectedTrigger: Selected
+ m_DisabledTrigger: Disabled
+ m_Interactable: 1
+ m_TargetGraphic: {fileID: 191506115}
+ m_OnClick:
+ m_PersistentCalls:
+ m_Calls:
+ - m_Target: {fileID: 253040317}
+ m_TargetAssemblyTypeName: AdditionalSampleButtons, Assembly-CSharp
+ m_MethodName: ApplicationNotRespondingNative
+ m_Mode: 1
+ m_Arguments:
+ m_ObjectArgument: {fileID: 0}
+ m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
+ m_IntArgument: 0
+ m_FloatArgument: 0
+ m_StringArgument:
+ m_BoolArgument: 0
+ m_CallState: 1
+--- !u!114 &191506115
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 191506112}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Material: {fileID: 0}
+ m_Color: {r: 1, g: 1, b: 1, a: 1}
+ m_RaycastTarget: 1
+ m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
+ m_Maskable: 1
+ m_OnCullStateChanged:
+ m_PersistentCalls:
+ m_Calls: []
+ m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
+ m_Type: 1
+ m_PreserveAspect: 0
+ m_FillCenter: 1
+ m_FillMethod: 4
+ m_FillAmount: 1
+ m_FillClockwise: 1
+ m_FillOrigin: 0
+ m_UseSpriteMesh: 0
+ m_PixelsPerUnitMultiplier: 1
+--- !u!222 &191506116
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 191506112}
+ m_CullTransparentMesh: 0
--- !u!1 &253040314
GameObject:
m_ObjectHideFlags: 0
@@ -156,9 +286,9 @@ RectTransform:
- {fileID: 1326160953}
- {fileID: 1983589452}
- {fileID: 978406552}
+ - {fileID: 191506113}
- {fileID: 2066465601}
m_Father: {fileID: 1665572489}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -234,9 +364,17 @@ Camera:
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
+ m_Iso: 200
+ m_ShutterSpeed: 0.005
+ m_Aperture: 16
+ m_FocusDistance: 10
+ m_FocalLength: 50
+ m_BladeCount: 5
+ m_Curvature: {x: 2, y: 11}
+ m_BarrelClipping: 0.25
+ m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
- m_FocalLength: 50
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
@@ -270,13 +408,13 @@ Transform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 519420028}
+ serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &582054325
GameObject:
@@ -318,13 +456,13 @@ Transform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 582054325}
+ serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &582054329
MonoBehaviour:
@@ -367,7 +505,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5216638424148094703}
- m_RootOrder: 5
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -405,7 +542,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 978406552}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -445,7 +581,7 @@ MonoBehaviour:
m_HorizontalOverflow: 1
m_VerticalOverflow: 0
m_LineSpacing: 1
- m_Text: Application-Not-Responding
+ m_Text: App Freeze in C#
--- !u!222 &765642148
CanvasRenderer:
m_ObjectHideFlags: 0
@@ -487,7 +623,6 @@ RectTransform:
m_Children:
- {fileID: 834374186}
m_Father: {fileID: 253040315}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -619,7 +754,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 802360430}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -699,7 +833,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1983589452}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -781,7 +914,6 @@ RectTransform:
m_Children:
- {fileID: 765642146}
m_Father: {fileID: 253040315}
- m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -915,7 +1047,6 @@ RectTransform:
m_Children:
- {fileID: 1857152829}
m_Father: {fileID: 253040315}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1047,7 +1178,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2066465601}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1096,6 +1226,85 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1583546086}
m_CullTransparentMesh: 0
+--- !u!1 &1603559785
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1603559786}
+ - component: {fileID: 1603559788}
+ - component: {fileID: 1603559787}
+ m_Layer: 5
+ m_Name: Text
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &1603559786
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1603559785}
+ m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 191506113}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 0, y: 0}
+ m_Pivot: {x: 0.5, y: 0.5}
+--- !u!114 &1603559787
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1603559785}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_Material: {fileID: 0}
+ m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
+ m_RaycastTarget: 1
+ m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
+ m_Maskable: 1
+ m_OnCullStateChanged:
+ m_PersistentCalls:
+ m_Calls: []
+ m_FontData:
+ m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
+ m_FontSize: 14
+ m_FontStyle: 0
+ m_BestFit: 0
+ m_MinSize: 0
+ m_MaxSize: 40
+ m_Alignment: 4
+ m_AlignByGeometry: 0
+ m_RichText: 1
+ m_HorizontalOverflow: 1
+ m_VerticalOverflow: 0
+ m_LineSpacing: 1
+ m_Text: App Freeze in Native
+--- !u!222 &1603559788
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1603559785}
+ m_CullTransparentMesh: 0
--- !u!1 &1665572488
GameObject:
m_ObjectHideFlags: 0
@@ -1131,7 +1340,6 @@ RectTransform:
- {fileID: 253040315}
- {fileID: 5216638424148094703}
m_Father: {fileID: 0}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1197,6 +1405,7 @@ Canvas:
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0
m_AdditionalShaderChannelsFlag: 0
+ m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
m_SortingOrder: 0
m_TargetDisplay: 0
@@ -1231,7 +1440,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1326160953}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1313,7 +1521,6 @@ RectTransform:
m_Children:
- {fileID: 908640125}
m_Father: {fileID: 253040315}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1447,7 +1654,6 @@ RectTransform:
m_Children:
- {fileID: 1583546087}
m_Father: {fileID: 253040315}
- m_RootOrder: 4
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1623,7 +1829,6 @@ RectTransform:
- {fileID: 5216638425317031111}
- {fileID: 735359052}
m_Father: {fileID: 1665572489}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0}
m_AnchorMax: {x: 0.5, y: 0}
@@ -1703,7 +1908,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5216638425317031111}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1765,7 +1969,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5216638425882040144}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1837,7 +2040,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5216638424148094703}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1997,7 +2199,6 @@ RectTransform:
m_Children:
- {fileID: 5216638424536828319}
m_Father: {fileID: 5216638424148094703}
- m_RootOrder: 4
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -2112,7 +2313,6 @@ RectTransform:
m_Children:
- {fileID: 5216638426142319615}
m_Father: {fileID: 5216638424148094703}
- m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -2152,7 +2352,6 @@ RectTransform:
m_Children:
- {fileID: 5216638425035397373}
m_Father: {fileID: 5216638424148094703}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -2301,7 +2500,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5216638424148094703}
- m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -2381,7 +2579,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5216638425805414493}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2453,7 +2650,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7152012675849339084}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -2483,7 +2679,6 @@ RectTransform:
- {fileID: 7152012675148913643}
- {fileID: 7152012676968016302}
m_Father: {fileID: 1665572489}
- m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
@@ -2561,7 +2756,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7152012675849339084}
- m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
@@ -2586,3 +2780,10 @@ GameObject:
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
+--- !u!1660057539 &9223372036854775807
+SceneRoots:
+ m_ObjectHideFlags: 0
+ m_Roots:
+ - {fileID: 1665572489}
+ - {fileID: 582054328}
+ - {fileID: 519420032}
diff --git a/samples/unity-of-bugs/Assets/Scripts/AdditionalSampleButtons.cs b/samples/unity-of-bugs/Assets/Scripts/AdditionalSampleButtons.cs
index 0bfac1503..80e6ab199 100644
--- a/samples/unity-of-bugs/Assets/Scripts/AdditionalSampleButtons.cs
+++ b/samples/unity-of-bugs/Assets/Scripts/AdditionalSampleButtons.cs
@@ -1,5 +1,8 @@
using System;
using System.Threading;
+#if UNITY_IOS
+using System.Runtime.InteropServices;
+#endif
using Sentry;
using Sentry.Unity;
using Unity.Burst;
@@ -48,10 +51,32 @@ public void CaptureMessageWithContext()
public void ApplicationNotResponding()
{
Debug.Log("Running Thread.Sleep() on the UI thread to trigger an ANR event.");
- Thread.Sleep(6 * 1000); // ANR detection currently defaults to 5 seconds
+ Thread.Sleep(10 * 1000); // ANR detection currently defaults to 5 seconds
Debug.Log("Thread.Sleep() finished.");
}
+ public void ApplicationNotRespondingNative()
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ Debug.Log("Stalling the main thread via Kotlin to trigger a native ANR event.");
+ using (var jo = new AndroidJavaObject("unity.of.bugs.KotlinPlugin"))
+ {
+ jo.CallStatic("applicationNotResponding");
+ }
+#elif UNITY_IOS && !UNITY_EDITOR
+ Debug.Log("Stalling the main thread via Objective-C to trigger a native ANR event.");
+ applicationNotResponding();
+#else
+ Debug.LogWarning("Native ANR sample requires running on Android or iOS.");
+#endif
+ }
+
+#if UNITY_IOS && !UNITY_EDITOR
+ // ObjectiveCPlugin.m
+ [DllImport("__Internal")]
+ private static extern void applicationNotResponding();
+#endif
+
public void Assert() => UnityEngine.Assertions.Assert.IsTrue(false);
diff --git a/samples/unity-of-bugs/Assets/Scripts/NativeSupport/KotlinPlugin.kt b/samples/unity-of-bugs/Assets/Scripts/NativeSupport/KotlinPlugin.kt
index 4f684b432..b975abfa7 100644
--- a/samples/unity-of-bugs/Assets/Scripts/NativeSupport/KotlinPlugin.kt
+++ b/samples/unity-of-bugs/Assets/Scripts/NativeSupport/KotlinPlugin.kt
@@ -18,4 +18,9 @@ object KotlinPlugin {
throw Exception("Kotlin 🐛 from a background thread.")
}
}
+ @JvmStatic fun applicationNotResponding() {
+ Log.i("test", "Stalling the main thread from Kotlin to trigger a native ANR.")
+ Thread.sleep(10 * 1000) // ANR detection currently defaults to 5 seconds
+ Log.i("test", "Kotlin main thread stall finished.")
+ }
}
diff --git a/samples/unity-of-bugs/Assets/Scripts/NativeSupport/ObjectiveCPlugin.m b/samples/unity-of-bugs/Assets/Scripts/NativeSupport/ObjectiveCPlugin.m
index e092f42eb..deaa19b51 100644
--- a/samples/unity-of-bugs/Assets/Scripts/NativeSupport/ObjectiveCPlugin.m
+++ b/samples/unity-of-bugs/Assets/Scripts/NativeSupport/ObjectiveCPlugin.m
@@ -16,4 +16,11 @@ void throwObjectiveC()
#endif
}
+void applicationNotResponding()
+{
+ NSLog(@"Stalling the main thread from Objective-C to trigger a native ANR.");
+ [NSThread sleepForTimeInterval:10.0]; // ANR detection currently defaults to 5 seconds
+ NSLog(@"Objective-C main thread stall finished.");
+}
+
NS_ASSUME_NONNULL_END
diff --git a/scripts/build-cocoa-sdk.ps1 b/scripts/build-cocoa-sdk.ps1
index deb8444b7..ef745627f 100644
--- a/scripts/build-cocoa-sdk.ps1
+++ b/scripts/build-cocoa-sdk.ps1
@@ -8,7 +8,9 @@ param(
[string]$iOSDestination,
[Parameter(Mandatory = $true)]
- [string]$macOSDestination
+ [string]$macOSDestination,
+
+ [switch]$Clean
)
Set-StrictMode -Version latest
@@ -25,6 +27,11 @@ $buildPath = Join-Path $CocoaRoot "XCFrameworkBuildPath"
$iOSXcframeworkPath = Join-Path $buildPath "Sentry-Dynamic-iOS.xcframework"
$macOSXcframeworkPath = Join-Path $buildPath "Sentry-Dynamic-macOS.xcframework"
+if ($Clean -and (Test-Path $buildPath)) {
+ Write-Host "Clean build requested — removing $buildPath" -ForegroundColor Yellow
+ Remove-Item -Path $buildPath -Recurse -Force
+}
+
Write-Host "Building Cocoa SDK from source..." -ForegroundColor Yellow
Push-Location $CocoaRoot
diff --git a/scripts/build-native-ndk-local.ps1 b/scripts/build-native-ndk-local.ps1
new file mode 100644
index 000000000..05773f490
--- /dev/null
+++ b/scripts/build-native-ndk-local.ps1
@@ -0,0 +1,124 @@
+<#
+.SYNOPSIS
+ Builds modules/sentry-native (NDK) and publishes the artifact to the local
+ Maven repo so modules/sentry-java consumes it instead of mavenCentral.
+
+.DESCRIPTION
+ Runs :sentry-native-ndk:publishToMavenLocal in modules/sentry-native/ndk,
+ producing io.sentry:sentry-native-ndk: at ~/.m2.
+
+ Requires mavenLocal() to be listed before mavenCentral() in
+ modules/sentry-java/settings.gradle.kts. The script verifies this and
+ aborts otherwise.
+
+ Because both repos publish the same version coordinate, Gradle's module
+ and transform caches can hold a previously-resolved mavenCentral copy.
+ The first time you switch to local (or when the module cache holds a
+ stale build), pass -PurgeCache to wipe sentry-native-ndk caches and
+ stop the Gradle daemon so the next build re-resolves from mavenLocal.
+
+.PARAMETER PurgeCache
+ Delete sentry-native-ndk from the Gradle module cache and the related
+ transform directories, then stop the Gradle daemon. Use when switching
+ from mavenCentral resolution or when the consumed artifact looks stale.
+
+.PARAMETER BuildJava
+ After publishing, run :sentry-android-ndk:assembleRelease in
+ modules/sentry-java to consume the freshly published artifact.
+
+.EXAMPLE
+ pwsh scripts/build-native-ndk-local.ps1
+ # Publish ndk to ~/.m2 (assumes caches are already clean).
+
+.EXAMPLE
+ pwsh scripts/build-native-ndk-local.ps1 -PurgeCache -BuildJava
+ # Wipe stale caches, publish, then rebuild sentry-android-ndk against
+ # the local artifact.
+#>
+
+param(
+ [switch] $PurgeCache,
+ [switch] $BuildJava
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
+$ndkDir = Join-Path $repoRoot 'modules/sentry-native/ndk'
+$javaDir = Join-Path $repoRoot 'modules/sentry-java'
+$javaSettings = Join-Path $javaDir 'settings.gradle.kts'
+
+if (-not (Test-Path $ndkDir)) {
+ throw "sentry-native NDK module not found at $ndkDir. Did you check out the submodule?"
+}
+if (-not (Test-Path $javaSettings)) {
+ throw "sentry-java settings.gradle.kts not found at $javaSettings."
+}
+
+$settingsContent = Get-Content $javaSettings -Raw
+$drmMatch = [regex]::Match($settingsContent, 'dependencyResolutionManagement\s*\{[^}]*repositories\s*\{(?[^}]*)\}')
+if (-not $drmMatch.Success) {
+ throw "Could not locate dependencyResolutionManagement.repositories block in $javaSettings."
+}
+$reposBlock = $drmMatch.Groups['repos'].Value
+$localIdx = $reposBlock.IndexOf('mavenLocal()')
+$centralIdx = $reposBlock.IndexOf('mavenCentral()')
+if ($localIdx -lt 0 -or $centralIdx -lt 0 -or $localIdx -gt $centralIdx) {
+ throw @"
+mavenLocal() must appear before mavenCentral() in
+$javaSettings (dependencyResolutionManagement block) so sentry-java
+resolves the locally-published sentry-native-ndk artifact. Reorder the
+repositories and re-run this script.
+"@
+}
+
+if ($PurgeCache) {
+ Write-Host '==> Purging Gradle caches for sentry-native-ndk'
+ $gradleCaches = Join-Path $HOME '.gradle/caches'
+ $moduleCache = Join-Path $gradleCaches 'modules-2/files-2.1/io.sentry/sentry-native-ndk'
+ if (Test-Path $moduleCache) {
+ Remove-Item -Recurse -Force $moduleCache
+ Write-Host " removed $moduleCache"
+ }
+
+ if (Test-Path $gradleCaches) {
+ $transformRoots = Get-ChildItem -Path $gradleCaches -Recurse -Force -ErrorAction SilentlyContinue `
+ | Where-Object { $_.FullName -like '*sentry-native-ndk*' } `
+ | ForEach-Object {
+ $idx = $_.FullName.IndexOf('/transformed/')
+ if ($idx -lt 0) { $idx = $_.FullName.IndexOf([IO.Path]::DirectorySeparatorChar + 'transformed' + [IO.Path]::DirectorySeparatorChar) }
+ if ($idx -ge 0) { $_.FullName.Substring(0, $idx) } else { $null }
+ } `
+ | Where-Object { $_ } `
+ | Sort-Object -Unique
+ foreach ($dir in $transformRoots) {
+ if (Test-Path $dir) {
+ Remove-Item -Recurse -Force $dir
+ Write-Host " removed $dir"
+ }
+ }
+ }
+
+ Write-Host '==> Stopping Gradle daemon to clear in-memory transform registry'
+ Push-Location $ndkDir
+ try { & ./gradlew --stop | Out-Null } finally { Pop-Location }
+}
+
+Write-Host '==> Publishing sentry-native-ndk to mavenLocal'
+Push-Location $ndkDir
+try {
+ & ./gradlew :sentry-native-ndk:publishToMavenLocal
+ if ($LASTEXITCODE -ne 0) { throw "publishToMavenLocal failed (exit $LASTEXITCODE)" }
+} finally { Pop-Location }
+
+if ($BuildJava) {
+ Write-Host '==> Building :sentry-android-ndk:assembleRelease against mavenLocal'
+ Push-Location $javaDir
+ try {
+ & ./gradlew :sentry-android-ndk:assembleRelease
+ if ($LASTEXITCODE -ne 0) { throw "sentry-android-ndk assembleRelease failed (exit $LASTEXITCODE)" }
+ } finally { Pop-Location }
+}
+
+Write-Host '==> Done.'
diff --git a/src/Sentry.Unity.Android/AnrHeartbeat.cs b/src/Sentry.Unity.Android/AnrHeartbeat.cs
new file mode 100644
index 000000000..5428db0f3
--- /dev/null
+++ b/src/Sentry.Unity.Android/AnrHeartbeat.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections;
+using Sentry.Extensibility;
+using UnityEngine;
+
+namespace Sentry.Unity.Android;
+
+///
+/// Drives a per-frame-ish heartbeat from the Unity main thread into sentry-java,
+/// so sentry-java's ANR watchdog can detect when the Unity main thread is stuck.
+///
+internal class AnrHeartbeat
+{
+ private readonly ISentryMonoBehaviour _monoBehaviour;
+ private readonly ISentryJava _sentryJava;
+ private readonly IDiagnosticLogger? _logger;
+ private readonly float _intervalSeconds;
+ private Coroutine? _coroutine;
+
+ public AnrHeartbeat(
+ ISentryMonoBehaviour monoBehaviour,
+ ISentryJava sentryJava,
+ TimeSpan anrTimeout,
+ IDiagnosticLogger? logger = null)
+ {
+ _monoBehaviour = monoBehaviour;
+ _sentryJava = sentryJava;
+ _logger = logger;
+ _intervalSeconds = Math.Max(0.001f, (float)(anrTimeout.TotalSeconds / 5));
+ }
+
+ public void Start()
+ {
+ if (_coroutine != null)
+ {
+ _logger?.LogDebug("ANR heartbeat already started; ignoring duplicate Start().");
+ return;
+ }
+
+ _coroutine = _monoBehaviour.StartCoroutine(Loop());
+ _monoBehaviour.ApplicationPausing += OnPause;
+ _monoBehaviour.ApplicationResuming += OnResume;
+ _logger?.LogDebug("ANR heartbeat started with interval {0}s", _intervalSeconds);
+ }
+
+ public void Stop()
+ {
+ _monoBehaviour.ApplicationPausing -= OnPause;
+ _monoBehaviour.ApplicationResuming -= OnResume;
+
+ if (_coroutine != null)
+ {
+ _monoBehaviour.StopCoroutine(_coroutine);
+ _coroutine = null;
+ }
+
+ _logger?.LogDebug("ANR heartbeat stopped.");
+ }
+
+ internal void Beat() => _sentryJava.NotifyAnrThreadAlive();
+
+ private IEnumerator Loop()
+ {
+ var wait = new WaitForSecondsRealtime(_intervalSeconds);
+ while (true)
+ {
+ Beat();
+ yield return wait;
+ }
+ }
+
+ private void OnPause()
+ {
+ if (_coroutine != null)
+ {
+ _monoBehaviour.StopCoroutine(_coroutine);
+ _coroutine = null;
+ }
+ }
+
+ private void OnResume()
+ {
+ if (_coroutine == null)
+ {
+ _coroutine = _monoBehaviour.StartCoroutine(Loop());
+ }
+ }
+}
diff --git a/src/Sentry.Unity.Android/SentryJava.cs b/src/Sentry.Unity.Android/SentryJava.cs
index 965d41ca9..a6e13cf90 100644
--- a/src/Sentry.Unity.Android/SentryJava.cs
+++ b/src/Sentry.Unity.Android/SentryJava.cs
@@ -45,6 +45,7 @@ public void WriteScope(
void AddAttachment(string path, string fileName, string? contentType);
void AddAttachmentBytes(byte[] data, string fileName, string? contentType);
void ClearAttachments();
+ void NotifyAnrThreadAlive();
}
///
@@ -68,6 +69,9 @@ internal class SentryJava : ISentryJava
private static AndroidJavaObject GetInternalSentryJava() => new AndroidJavaClass("io.sentry.android.core.InternalSentrySdk");
private static AndroidJavaObject GetSentryJava() => new AndroidJavaClass("io.sentry.Sentry");
+ // The heartbeat registry is sentry-java's @ApiStatus.Internal entry point for hybrid SDKs;
+ // it deliberately bypasses the public Sentry/Scopes plumbing.
+ private static AndroidJavaObject GetAnrHeartbeatRegistry() => new AndroidJavaClass("io.sentry.AnrHeartbeatRegistry");
public SentryJava(IDiagnosticLogger? logger, IAndroidJNI? androidJNI = null)
{
@@ -145,6 +149,14 @@ public void Init(SentryUnityOptions options)
androidOptions.Call("setReportHistoricalAnrs", options.AndroidReportHistoricalAnrs);
androidOptions.Call("setAttachAnrThreadDump", options.AndroidAttachAnrThreadDump);
+ if (options.AndroidNativeAnrEnabled)
+ {
+ using var processClass = new AndroidJavaClass("android.os.Process");
+ var mainThreadTid = processClass.CallStatic("myTid");
+ _logger?.LogDebug("Setting ANR thread id on sentry-java: {0}", mainThreadTid);
+ androidOptions.Call("setAnrThreadId", (long)mainThreadTid);
+ }
+
using (var logsOptions = androidOptions.Call("getLogs"))
{
logsOptions.Call("setEnabled", options.EnableLogs);
@@ -415,6 +427,25 @@ public void ClearAttachments()
});
}
+ public void NotifyAnrThreadAlive()
+ {
+ if (_closed)
+ {
+ _logger?.LogInfo("SentryJava is closed, skipping 'NotifyAnrThreadAlive'");
+ return;
+ }
+
+ try
+ {
+ using var registry = GetAnrHeartbeatRegistry();
+ registry.CallStatic("notifyAlive");
+ }
+ catch (Exception e)
+ {
+ _logger?.LogError(e, "Calling 'SentryJava.NotifyAnrThreadAlive' failed.");
+ }
+ }
+
// 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
{
diff --git a/src/Sentry.Unity.Android/SentryNativeAndroid.cs b/src/Sentry.Unity.Android/SentryNativeAndroid.cs
index 468e796f4..350ec9ea2 100644
--- a/src/Sentry.Unity.Android/SentryNativeAndroid.cs
+++ b/src/Sentry.Unity.Android/SentryNativeAndroid.cs
@@ -15,6 +15,11 @@ public static class SentryNativeAndroid
// parameter on `Configure` due SentryNativeAndroid being public
internal static ISentryJava? SentryJava;
+ // Test seam: lets tests observe heartbeat construction without running coroutines.
+ internal static Func? HeartbeatFactory;
+
+ internal static AnrHeartbeat? Heartbeat;
+
private static IDiagnosticLogger? Logger;
///
@@ -67,6 +72,17 @@ public static void Configure(SentryUnityOptions options)
}
}
+ if (options.AndroidNativeAnrEnabled)
+ {
+ Logger?.LogDebug("Disabling the C# ANR watchdog on Android - sentry-java handles app hang detection.");
+ options.DisableAnrIntegration();
+
+ Heartbeat = HeartbeatFactory is not null
+ ? HeartbeatFactory(SentryJava!, options)
+ : new AnrHeartbeat(SentryMonoBehaviour.Instance, SentryJava!, options.AnrTimeout, Logger);
+ Heartbeat.Start();
+ }
+
Logger?.LogDebug("Configuring scope sync");
options.NativeContextWriter = new NativeContextWriter(SentryJava);
@@ -132,6 +148,12 @@ public static void Close(SentryUnityOptions options)
{
Logger?.LogInfo("Attempting to close the Android SDK");
+ if (Heartbeat is not null)
+ {
+ Heartbeat.Stop();
+ Heartbeat = null;
+ }
+
if (!options.IsNativeSupportEnabled())
{
Logger?.LogDebug("Android Native Support is not enabled. Skipping closing the Android SDK");
diff --git a/src/Sentry.Unity/SentryMonoBehaviour.cs b/src/Sentry.Unity/SentryMonoBehaviour.cs
index 2d93d8f0d..81a6e0f6b 100644
--- a/src/Sentry.Unity/SentryMonoBehaviour.cs
+++ b/src/Sentry.Unity/SentryMonoBehaviour.cs
@@ -10,7 +10,9 @@ namespace Sentry.Unity;
internal interface ISentryMonoBehaviour
{
event Action? ApplicationResuming;
+ event Action? ApplicationPausing;
public Coroutine StartCoroutine(IEnumerator routine);
+ public void StopCoroutine(Coroutine routine);
public void QueueCoroutine(IEnumerator routine);
}
diff --git a/src/Sentry.Unity/SentryUnityOptionsExtensions.cs b/src/Sentry.Unity/SentryUnityOptionsExtensions.cs
index 38652449e..cf7a81e75 100644
--- a/src/Sentry.Unity/SentryUnityOptionsExtensions.cs
+++ b/src/Sentry.Unity/SentryUnityOptionsExtensions.cs
@@ -1,4 +1,6 @@
+using System.Linq;
using Sentry.Extensibility;
+using Sentry.Integrations;
using Sentry.Unity.Integrations;
using UnityEngine;
@@ -106,6 +108,13 @@ public static void DisableUnhandledExceptionCapture(this SentryUnityOptions opti
public static void DisableAnrIntegration(this SentryUnityOptions options) =>
options.RemoveIntegration();
+ // Bridge for cross-assembly tests (e.g. Sentry.Unity.Android.Tests) that need to inspect
+ // integrations. SentryOptions.HasIntegration and SentryOptions.Integrations are internal
+ // to Sentry, whose InternalsVisibleTo list doesn't include the Android tests project —
+ // but Sentry.Unity.Android.Tests does have access to Sentry.Unity's internals.
+ internal static bool HasIntegration(this SentryUnityOptions options) where T : ISdkIntegration
+ => options.Integrations.Any(i => i is T);
+
///
/// Disables the automatic filtering of Bad Gateway exception of type Exception.
///
diff --git a/test/Sentry.Unity.Android.Tests/AnrHeartbeatTests.cs b/test/Sentry.Unity.Android.Tests/AnrHeartbeatTests.cs
new file mode 100644
index 000000000..5a8856421
--- /dev/null
+++ b/test/Sentry.Unity.Android.Tests/AnrHeartbeatTests.cs
@@ -0,0 +1,120 @@
+using System;
+using NUnit.Framework;
+using Sentry.Unity.Tests.SharedClasses;
+using Sentry.Unity.Tests.Stubs;
+using UnityEngine;
+
+namespace Sentry.Unity.Android.Tests;
+
+public class AnrHeartbeatTests
+{
+ private TestLogger _logger = null!;
+ private TestSentryJava _sentryJava = null!;
+ private TestSentryMonoBehaviour _monoBehaviour = null!;
+ private GameObject _gameObject = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _logger = new TestLogger();
+ _sentryJava = new TestSentryJava();
+ _gameObject = new GameObject(nameof(AnrHeartbeatTests));
+ _monoBehaviour = _gameObject.AddComponent();
+ }
+
+ [TearDown]
+ public void TearDown() => UnityEngine.Object.DestroyImmediate(_gameObject);
+
+ [Test]
+ public void Beat_CallsNotifyAnrThreadAlive()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+
+ sut.Beat();
+ sut.Beat();
+
+ Assert.AreEqual(2, _sentryJava.NotifyAnrThreadAliveCount);
+ }
+
+ [Test]
+ public void Start_StartsCoroutineAndSubscribesToPauseResume()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+
+ sut.Start();
+
+ Assert.IsTrue(_monoBehaviour.StartCoroutineCalled);
+ }
+
+ [Test]
+ public void OnPause_StopsCoroutine()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+ sut.Start();
+
+ _monoBehaviour.PauseApplication();
+
+ Assert.AreEqual(1, _monoBehaviour.StopCoroutineCallCount);
+ }
+
+ [Test]
+ public void OnResume_RestartsCoroutine()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+ sut.Start();
+ _monoBehaviour.PauseApplication();
+
+ _monoBehaviour.ResumeApplication();
+
+ // First start + restart on resume = StartCoroutineCalled stays true (it's a flag),
+ // verify by checking the heartbeat is once again live: a subsequent pause stops again.
+ _monoBehaviour.PauseApplication();
+ Assert.AreEqual(2, _monoBehaviour.StopCoroutineCallCount);
+ }
+
+ [Test]
+ public void Constructor_ClampsIntervalAboveZero()
+ {
+ // Should not throw even with a zero timeout.
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.Zero, _logger);
+ Assert.DoesNotThrow(() => sut.Beat());
+ }
+
+ [Test]
+ public void Start_CalledTwice_IsIdempotent()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+ sut.Start();
+ sut.Start();
+
+ // A single pause should produce exactly one StopCoroutine call. If Start were
+ // not idempotent, the second pause-resume cycle would observe duplicate handlers.
+ _monoBehaviour.PauseApplication();
+ Assert.AreEqual(1, _monoBehaviour.StopCoroutineCallCount);
+ }
+
+ [Test]
+ public void Stop_StopsCoroutineAndUnsubscribes()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+ sut.Start();
+
+ sut.Stop();
+
+ // Stop itself counts as a coroutine stop.
+ Assert.AreEqual(1, _monoBehaviour.StopCoroutineCallCount);
+
+ // Subsequent pause must not trigger another stop (handler should be unsubscribed).
+ _monoBehaviour.PauseApplication();
+ Assert.AreEqual(1, _monoBehaviour.StopCoroutineCallCount);
+ }
+
+ [Test]
+ public void Stop_BeforeStart_DoesNothing()
+ {
+ var sut = new AnrHeartbeat(_monoBehaviour, _sentryJava, TimeSpan.FromSeconds(5), _logger);
+
+ Assert.DoesNotThrow(() => sut.Stop());
+ Assert.AreEqual(0, _monoBehaviour.StopCoroutineCallCount);
+ }
+}
diff --git a/test/Sentry.Unity.Android.Tests/Sentry.Unity.Android.Tests.csproj b/test/Sentry.Unity.Android.Tests/Sentry.Unity.Android.Tests.csproj
index 45576d0ff..65eed1bbe 100644
--- a/test/Sentry.Unity.Android.Tests/Sentry.Unity.Android.Tests.csproj
+++ b/test/Sentry.Unity.Android.Tests/Sentry.Unity.Android.Tests.csproj
@@ -12,5 +12,8 @@
%(RecursiveDir)%(Filename)%(Extension)
+
+ Stubs/SentryTestMonoBehaviour.cs
+
diff --git a/test/Sentry.Unity.Android.Tests/SentryJavaTests.cs b/test/Sentry.Unity.Android.Tests/SentryJavaTests.cs
index a8ab9696a..2c6e65bd3 100644
--- a/test/Sentry.Unity.Android.Tests/SentryJavaTests.cs
+++ b/test/Sentry.Unity.Android.Tests/SentryJavaTests.cs
@@ -142,6 +142,17 @@ public void RunJniSafe_AfterClose_SkipsActionAndLogsWarning()
"Should log warning when trying to queue action after Close()");
}
+ [Test]
+ public void TestSentryJava_RecordsNotifyAnrThreadAlive()
+ {
+ var stub = new TestSentryJava();
+
+ stub.NotifyAnrThreadAlive();
+ stub.NotifyAnrThreadAlive();
+
+ Assert.AreEqual(2, stub.NotifyAnrThreadAliveCount);
+ }
+
internal class TestAndroidJNI : IAndroidJNI
{
public bool AttachCalled { get; private set; }
diff --git a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs
index 09d2057fb..567eedf61 100644
--- a/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs
+++ b/test/Sentry.Unity.Android.Tests/SentryNativeAndroidTests.cs
@@ -4,6 +4,7 @@
using NUnit.Framework;
using Sentry.Unity.Tests.SharedClasses;
using Sentry.Unity.Tests.Stubs;
+using UnityEngine;
namespace Sentry.Unity.Android.Tests;
@@ -193,4 +194,103 @@ public void Configure_NativeInitFails_LogsErrorAndReturns()
Assert.Null(options.ScopeObserver);
}
+
+ [Test]
+ public void Configure_AndroidNativeAnrEnabled_RemovesAnrIntegration()
+ {
+ var options = new SentryUnityOptions { AndroidNativeAnrEnabled = true };
+ Assert.IsTrue(options.HasIntegration()); // sanity
+
+ SentryNativeAndroid.Configure(options);
+
+ Assert.IsFalse(options.HasIntegration());
+ }
+
+ [Test]
+ public void Configure_AndroidNativeAnrDisabled_KeepsAnrIntegration()
+ {
+ var options = new SentryUnityOptions { AndroidNativeAnrEnabled = false };
+
+ SentryNativeAndroid.Configure(options);
+
+ Assert.IsTrue(options.HasIntegration());
+ }
+
+ [Test]
+ public void Configure_AndroidNativeAnrEnabled_StartsHeartbeat()
+ {
+ var options = new SentryUnityOptions { AndroidNativeAnrEnabled = true };
+ AnrHeartbeat? built = null;
+ var go = new GameObject(nameof(Configure_AndroidNativeAnrEnabled_StartsHeartbeat));
+ try
+ {
+ var monoBehaviour = go.AddComponent();
+ SentryNativeAndroid.HeartbeatFactory = (java, opts) =>
+ {
+ built = new AnrHeartbeat(monoBehaviour, java, opts.AnrTimeout);
+ return built;
+ };
+
+ SentryNativeAndroid.Configure(options);
+
+ Assert.NotNull(built);
+ Assert.IsTrue(monoBehaviour.StartCoroutineCalled);
+ }
+ finally
+ {
+ SentryNativeAndroid.HeartbeatFactory = null;
+ SentryNativeAndroid.Heartbeat = null;
+ UnityEngine.Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void Configure_AndroidNativeAnrDisabled_DoesNotStartHeartbeat()
+ {
+ var options = new SentryUnityOptions { AndroidNativeAnrEnabled = false };
+ var built = false;
+ SentryNativeAndroid.HeartbeatFactory = (_, _) =>
+ {
+ built = true;
+ return null!;
+ };
+ try
+ {
+ SentryNativeAndroid.Configure(options);
+ Assert.IsFalse(built);
+ }
+ finally
+ {
+ SentryNativeAndroid.HeartbeatFactory = null;
+ SentryNativeAndroid.Heartbeat = null;
+ }
+ }
+
+ [Test]
+ public void Close_StopsHeartbeat()
+ {
+ var options = new SentryUnityOptions { AndroidNativeAnrEnabled = true };
+ var go = new GameObject(nameof(Close_StopsHeartbeat));
+ try
+ {
+ var monoBehaviour = go.AddComponent();
+ SentryNativeAndroid.HeartbeatFactory = (java, opts) =>
+ new AnrHeartbeat(monoBehaviour, java, opts.AnrTimeout);
+
+ SentryNativeAndroid.Configure(options);
+ Assert.IsNotNull(SentryNativeAndroid.Heartbeat); // sanity
+ var stopCountBefore = monoBehaviour.StopCoroutineCallCount;
+
+ SentryNativeAndroid.Close(options);
+
+ Assert.IsNull(SentryNativeAndroid.Heartbeat);
+ Assert.AreEqual(stopCountBefore + 1, monoBehaviour.StopCoroutineCallCount);
+ }
+ finally
+ {
+ SentryNativeAndroid.HeartbeatFactory = null;
+ SentryNativeAndroid.Heartbeat = null;
+ UnityEngine.Object.DestroyImmediate(go);
+ }
+ }
}
diff --git a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs
index 195d94221..d823b177f 100644
--- a/test/Sentry.Unity.Android.Tests/TestSentryJava.cs
+++ b/test/Sentry.Unity.Android.Tests/TestSentryJava.cs
@@ -60,4 +60,8 @@ public void AddAttachment(string path, string fileName, string? contentType) { }
public void AddAttachmentBytes(byte[] data, string fileName, string? contentType) { }
public void ClearAttachments() { }
+
+ public int NotifyAnrThreadAliveCount { get; private set; }
+
+ public void NotifyAnrThreadAlive() => NotifyAnrThreadAliveCount++;
}
diff --git a/test/Sentry.Unity.Tests/SentryMonoBehaviourTests.cs b/test/Sentry.Unity.Tests/SentryMonoBehaviourTests.cs
index 586b1dea1..01fc950bb 100644
--- a/test/Sentry.Unity.Tests/SentryMonoBehaviourTests.cs
+++ b/test/Sentry.Unity.Tests/SentryMonoBehaviourTests.cs
@@ -128,4 +128,24 @@ IEnumerator TestCoroutine()
Assert.IsTrue(coroutineExecuted);
}
+
+ [Test]
+ public void TestSentryMonoBehaviour_RaisesApplicationPausing()
+ {
+ var go = new GameObject(nameof(TestSentryMonoBehaviour_RaisesApplicationPausing));
+ try
+ {
+ var sut = go.AddComponent();
+ var raised = 0;
+ ((ISentryMonoBehaviour)sut).ApplicationPausing += () => raised++;
+
+ sut.PauseApplication();
+
+ Assert.AreEqual(1, raised);
+ }
+ finally
+ {
+ Object.DestroyImmediate(go);
+ }
+ }
}
diff --git a/test/Sentry.Unity.Tests/Stubs/SentryTestMonoBehaviour.cs b/test/Sentry.Unity.Tests/Stubs/SentryTestMonoBehaviour.cs
index f911d21b9..661a79413 100644
--- a/test/Sentry.Unity.Tests/Stubs/SentryTestMonoBehaviour.cs
+++ b/test/Sentry.Unity.Tests/Stubs/SentryTestMonoBehaviour.cs
@@ -7,9 +7,12 @@ namespace Sentry.Unity.Tests.Stubs;
internal class TestSentryMonoBehaviour : MonoBehaviour, ISentryMonoBehaviour
{
public event System.Action? ApplicationResuming;
+ public event System.Action? ApplicationPausing;
public void ResumeApplication() => ApplicationResuming?.Invoke();
+ public void PauseApplication() => ApplicationPausing?.Invoke();
public bool StartCoroutineCalled { get; private set; }
+ public int StopCoroutineCallCount { get; private set; }
public new Coroutine StartCoroutine(IEnumerator routine)
{
@@ -17,6 +20,12 @@ internal class TestSentryMonoBehaviour : MonoBehaviour, ISentryMonoBehaviour
return base.StartCoroutine(routine);
}
+ public new void StopCoroutine(Coroutine routine)
+ {
+ StopCoroutineCallCount++;
+ base.StopCoroutine(routine);
+ }
+
public void QueueCoroutine(IEnumerator routine)
{
// For tests, assume we're on the main thread and start immediately