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