diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a1115f8ae..709f949a4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) + ## 8.45.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index c32b05892f9..5757ef5a8c0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -256,8 +256,10 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { node.setType(className); try { - final String identifier = ViewUtils.getResourceId(view); - node.setIdentifier(identifier); + final @Nullable String identifier = ViewUtils.resolveResourceId(view); + if (identifier != null) { + node.setIdentifier(identifier); + } } catch (Throwable e) { // ignored } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index c85fb80dc35..b319782c5c5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -1,6 +1,5 @@ package io.sentry.android.core.internal.gestures; -import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; @@ -42,13 +41,12 @@ && isViewScrollable(view, isAndroidXAvailable.getValue())) { } private UiElement createUiElement(final @NotNull View targetView) { - try { - final String resourceName = ViewUtils.getResourceId(targetView); - @Nullable String className = ClassUtil.getClassName(targetView); - return new UiElement(targetView, className, resourceName, null, ORIGIN); - } catch (Resources.NotFoundException ignored) { + final @Nullable String resourceName = ViewUtils.resolveResourceId(targetView); + if (resourceName == null) { return null; } + @Nullable String className = ClassUtil.getClassName(targetView); + return new UiElement(targetView, className, resourceName, null, ORIGIN); } private static boolean isViewTappable(final @NotNull View view) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 501a05a5007..82708fc7d7b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -104,13 +104,12 @@ private static boolean touchWithinBounds( * @return human-readable view id */ static String getResourceIdWithFallback(final @NotNull View view) { - final int viewId = view.getId(); - try { - return getResourceId(view); - } catch (Resources.NotFoundException e) { + final @Nullable String resourceId = resolveResourceId(view); + if (resourceId == null) { // fall back to hex representation of the id - return "0x" + Integer.toString(viewId, 16); + return "0x" + Integer.toString(view.getId(), 16); } + return resourceId; } /** @@ -121,15 +120,36 @@ static String getResourceIdWithFallback(final @NotNull View view) { * @throws Resources.NotFoundException in case the view id was not found */ public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { + final @Nullable String resourceId = resolveResourceId(view); + if (resourceId == null) { + throw new Resources.NotFoundException(); + } + return resourceId; + } + + /** + * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or + * {@code null} when the view has no resource-backed id. Unlike {@link #getResourceId(View)} this + * does not throw for unresolved ids, avoiding exception-driven control flow on hot, main-thread + * paths such as view-hierarchy snapshots and gesture target resolution. + * + * @param view - the view whose id is being retrieved + * @return human-readable view id, or {@code null} if it cannot be resolved + */ + public static @Nullable String resolveResourceId(final @NotNull View view) { final int viewId = view.getId(); if (viewId == View.NO_ID || isViewIdGenerated(viewId)) { - throw new Resources.NotFoundException(); + return null; } final Resources resources = view.getContext().getResources(); - if (resources != null) { + if (resources == null) { + return ""; + } + try { return resources.getResourceEntryName(viewId); + } catch (Resources.NotFoundException e) { + return null; } - return ""; } private static boolean isViewIdGenerated(int id) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 77a38e6ccc1..e20af78edbe 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -6,6 +6,7 @@ import android.view.View import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow @@ -80,6 +81,65 @@ class ViewUtilsTest { verify(context, never()).resources } + @Test + fun `resolveResourceId returns resource name when available`() { + val view = + mock { + whenever(it.id).doReturn(View.generateViewId()) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view") + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertEquals("test_view", ViewUtils.resolveResourceId(view)) + } + + @Test + fun `resolveResourceId returns null without throwing for generated id`() { + val context = mock() + val view = + mock { + // View.generateViewId() starts with 1 + whenever(it.id).doReturn(1) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.resolveResourceId(view)) + verify(context, never()).resources + } + + @Test + fun `resolveResourceId returns null without throwing when view has no id`() { + val context = mock() + val view = + mock { + whenever(it.id).doReturn(View.NO_ID) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.resolveResourceId(view)) + verify(context, never()).resources + } + + @Test + fun `resolveResourceId returns null without throwing when resource not found`() { + val view = + mock { + whenever(it.id).doReturn(1234) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException()) + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.resolveResourceId(view)) + } + @Test fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() { val view =