From 222087cbeb0ff8c6087c29e30a10a5762451f735 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 12 Oct 2026 10:13:53 +0200 Subject: [PATCH 1/3] feat(android): Add ANR warning breadcrumbs on Android 17+ --- build.gradle.kts | 5 +- .../api/sentry-android-core.api | 2 +- .../core/AndroidOptionsInitializer.java | 2 +- .../android/core/AppLifecycleIntegration.java | 208 +++++++++++++- .../core/AppLifecycleIntegrationTest.kt | 271 +++++++++++++++++- .../android/AnrWarningBreadcrumbTest.kt | 85 ++++++ .../io/sentry/uitest/android/BaseUiTest.kt | 2 +- .../src/main/AndroidManifest.xml | 2 + .../sentry/uitest/android/SlowStartService.kt | 29 ++ 9 files changed, 599 insertions(+), 7 deletions(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/AnrWarningBreadcrumbTest.kt create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/SlowStartService.kt diff --git a/build.gradle.kts b/build.gradle.kts index 1776997de2e..d02127a93d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -140,7 +140,10 @@ subprojects { // FileDescriptor fields in ApplicationSharedMemory; without this, setup fails with // "Failed to interact with raw FileDescriptor internals". tasks.withType().configureEach { - jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED") + jvmArgs( + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", + ) } } } diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index efd776cabb8..b17cb212183 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -180,7 +180,7 @@ public final class io/sentry/android/core/AppComponentsBreadcrumbsIntegration : } public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/Integration, java/io/Closeable { - public fun ()V + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V public fun close ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 12ffb90356c..27e53ce2c88 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -452,7 +452,7 @@ static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it - options.addIntegration(new AppLifecycleIntegration()); + options.addIntegration(new AppLifecycleIntegration(context, buildInfoProvider)); // AnrIntegration must be installed before ReplayIntegration, as ReplayIntegration relies on // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 9fd90b23099..18ced337040 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -2,6 +2,14 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.AnrTypes; +import android.app.AnrWarningResult; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.Breadcrumb; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; @@ -11,10 +19,23 @@ import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; +/** + * Tracks the application lifecycle to drive session tracking and {@code app.lifecycle} breadcrumbs. + * + *

On Android 17 (API 37) and above it additionally subscribes to {@link + * ActivityManager#registerAnrWarningListener}, which the system invokes while an ANR timeout is + * still running — before the app is actually declared unresponsive. Each warning becomes an {@code + * app.anr} breadcrumb carrying the ANR type, the system description, and how much of the timeout + * budget has already been consumed. Because the breadcrumb is attached to the scope, a crash or + * error captured afterwards shows that the main thread was already stalling. + */ public final class AppLifecycleIntegration implements Integration, Closeable { private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @@ -22,6 +43,17 @@ public final class AppLifecycleIntegration implements Integration, Closeable { private @Nullable SentryAndroidOptions options; + private final @NotNull Context context; + private final @NotNull BuildInfoProvider buildInfoProvider; + private @Nullable Consumer anrWarningConsumer; + private boolean isClosed; + + public AppLifecycleIntegration( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = context.getApplicationContext(); + this.buildInfoProvider = buildInfoProvider; + } + @Override public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { Objects.requireNonNull(scopes, "Scopes are required"); @@ -61,14 +93,21 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions AppState.getInstance().addAppStateListener(watcher); } + if (this.options.isEnableAppLifecycleBreadcrumbs()) { + scheduleRegisterAnrWarningListener(scopes, this.options); + } + options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed."); addIntegrationToSdkVersion("AppLifecycle"); } } - private void removeObserver() { + private void removeObservers() { final @Nullable LifecycleWatcher watcherRef; try (final ISentryLifecycleToken ignored = lock.acquire()) { + // Set before scheduling the unregister, so a register task that is still queued bails out + // instead of resurrecting the listener after close. + isClosed = true; watcherRef = watcher; watcher = null; } @@ -79,11 +118,176 @@ private void removeObserver() { options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration removed."); } } + + scheduleUnregisterAnrWarningListener(); + } + + /** + * {@link ActivityManager#registerAnrWarningListener} is a binder call, so it must not run on the + * caller's thread — {@link #register} is driven from {@code Sentry.init} on the main thread. + */ + private void scheduleRegisterAnrWarningListener( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.CINNAMON_BUN) { + options + .getLogger() + .log(SentryLevel.DEBUG, "ANR warning listener needs Android API 37 and above."); + return; + } + + try { + options.getExecutorService().submit(() -> registerAnrWarningListener(scopes, options)); + } catch (RejectedExecutionException e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to submit the ANR warning listener registration to the executor service.", + e); + } + } + + private void scheduleUnregisterAnrWarningListener() { + final @Nullable SentryAndroidOptions options = this.options; + if (options == null) { + return; + } + + // unregisterAnrWarningListener is idempotent, so running it twice after a dropped task is + // harmless, whereas never running it would leak the listener for the process lifetime. + try { + final @NotNull Future future = + options.getExecutorService().submit(() -> unregisterAnrWarningListener(options)); + if (future.isCancelled()) { + unregisterAnrWarningListener(options); + } + } catch (RejectedExecutionException e) { + unregisterAnrWarningListener(options); + } + } + + /** Runs on the executor service. Guarded by {@link #scheduleRegisterAnrWarningListener}. */ + // Lint cannot see through buildInfoProvider, which scheduleRegisterAnrWarningListener checks + // before this ever runs. The suppression is scoped so that register() stays lint-checked. + @SuppressLint("NewApi") + private void registerAnrWarningListener( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + final @Nullable ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + options + .getLogger() + .log( + SentryLevel.INFO, + "ActivityManager is not available, ANR warning listener not installed."); + return; + } + + final @NotNull Consumer consumer = + result -> scopes.addBreadcrumb(createAnrBreadcrumb(result)); + + try (final ISentryLifecycleToken ignored = lock.acquire()) { + if (isClosed || anrWarningConsumer != null) { + return; + } + try { + // Runnable::run keeps the callback on the binder thread that delivers it. Hopping to the + // main thread would be useless here, because an impending ANR means it is already stalled. + activityManager.registerAnrWarningListener(Runnable::run, consumer); + } catch (RuntimeException e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to register the ANR warning listener.", e); + return; + } + anrWarningConsumer = consumer; + } + + options.getLogger().log(SentryLevel.DEBUG, "ANR warning listener installed."); + } + + /** Runs on the executor service, or inline when the executor rejected the task. */ + // Only gets past the null check when registration succeeded, which implies API 37 or above. + @SuppressLint("NewApi") + private void unregisterAnrWarningListener(final @NotNull SentryAndroidOptions options) { + final @Nullable Consumer consumerRef; + try (final ISentryLifecycleToken ignored = lock.acquire()) { + consumerRef = anrWarningConsumer; + anrWarningConsumer = null; + } + + if (consumerRef == null) { + return; + } + + final @Nullable ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return; + } + try { + activityManager.unregisterAnrWarningListener(consumerRef); + } catch (RuntimeException e) { + options + .getLogger() + .log(SentryLevel.WARNING, "Failed to unregister the ANR warning listener.", e); + } + } + + @RequiresApi(api = Build.VERSION_CODES.CINNAMON_BUN) + static @NotNull Breadcrumb createAnrBreadcrumb(final @NotNull AnrWarningResult result) { + final Breadcrumb breadcrumb = new Breadcrumb(); + breadcrumb.setType("system"); + breadcrumb.setCategory("app.anr"); + breadcrumb.setLevel(SentryLevel.WARNING); + breadcrumb.setMessage("ANR warning"); + breadcrumb.setData("anr_type", getAnrTypeName(result.getAnrType())); + breadcrumb.setData("anr_id", result.getAnrId()); + breadcrumb.setData("timeout_ms", result.getTimeoutMillis()); + breadcrumb.setData("consumed_ms", result.getConsumedMillis()); + // The system leaves this empty for some ANR types, so do not carry a blank value. + final @Nullable String description = result.getDescription(); + if (description != null && !description.isEmpty()) { + breadcrumb.setData("description", description); + } + return breadcrumb; + } + + // AnrTypes only holds compile-time int constants, which javac inlines, so this needs no + // runtime API 37 symbol and stays callable (and testable) on every API level. + @SuppressLint("NewApi") + static @NotNull String getAnrTypeName(final int anrType) { + switch (anrType) { + case AnrTypes.ANR_TYPE_OTHER: + return "other"; + case AnrTypes.ANR_TYPE_INPUT_DISPATCH_NO_FOCUSED_WINDOW: + return "input_dispatch_no_focused_window"; + case AnrTypes.ANR_TYPE_INPUT_DISPATCH: + return "input_dispatch"; + case AnrTypes.ANR_TYPE_BROADCAST_OF_INTENT: + return "broadcast_of_intent"; + case AnrTypes.ANR_TYPE_START_FOREGROUND_SERVICE: + return "start_foreground_service"; + case AnrTypes.ANR_TYPE_EXECUTE_SERVICE: + return "execute_service"; + case AnrTypes.ANR_TYPE_CONTENT_PROVIDER_NOT_RESPONDING: + return "content_provider_not_responding"; + case AnrTypes.ANR_TYPE_APP_TRIGGERED: + return "app_triggered"; + case AnrTypes.ANR_TYPE_FOREGROUND_SHORT_SERVICE_TIMEOUT: + return "foreground_short_service_timeout"; + case AnrTypes.ANR_TYPE_JOB_SERVICE_START: + return "job_service_start"; + case AnrTypes.ANR_TYPE_APPLICATION_START: + return "application_start"; + default: + return "unknown (" + anrType + ")"; + } } @Override public void close() throws IOException { - removeObserver(); + removeObservers(); // TODO: probably should move it to Scopes.close(), but that'd require a new interface and // different implementations for Java and Android. This is probably fine like this too, because // integrations are closed in the same place diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt index 896673085c2..505e5d84ffc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt @@ -1,15 +1,37 @@ package io.sentry.android.core +import android.app.ActivityManager +import android.app.AnrTypes +import android.app.AnrWarningResult +import android.app.Application +import android.content.Context import android.os.Looper +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb import io.sentry.IScopes +import io.sentry.ISentryExecutorService +import io.sentry.SentryLevel +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService +import io.sentry.test.NonOverridableNoOpSentryExecutorService import java.util.concurrent.CountDownLatch +import java.util.function.Consumer import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.check import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class AppLifecycleIntegrationTest { @@ -18,12 +40,150 @@ class AppLifecycleIntegrationTest { val options = SentryAndroidOptions() fun getSut(): AppLifecycleIntegration { - return AppLifecycleIntegration() + return AppLifecycleIntegration(ApplicationProvider.getApplicationContext(), mock()) } } private val fixture = Fixture() + /** + * Robolectric has no shadow for registerAnrWarningListener and offers no way to deliver a + * warning, so the ActivityManager itself is mocked. Everything around it is real: the mock is + * injected into the Robolectric application context as the system service. + */ + private fun getSutWithActivityManager( + activityManager: ActivityManager, + sdkVersion: Int = 37, + executorService: ISentryExecutorService = ImmediateExecutorService(), + ): AppLifecycleIntegration { + val application = ApplicationProvider.getApplicationContext() + shadowOf(application).setSystemService(Context.ACTIVITY_SERVICE, activityManager) + val buildInfoProvider = mock() + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(sdkVersion) + fixture.options.executorService = executorService + return AppLifecycleIntegration(application, buildInfoProvider) + } + + private fun anrWarningResult( + anrType: Int = AnrTypes.ANR_TYPE_BROADCAST_OF_INTENT, + anrId: Int = 7, + description: String? = "Broadcast of Intent { act=android.intent.action.SCREEN_ON }", + timeoutMillis: Long = 10_000, + consumedMillis: Long = 7_500, + ): AnrWarningResult = + mock().also { + whenever(it.anrType).thenReturn(anrType) + whenever(it.anrId).thenReturn(anrId) + whenever(it.description).thenReturn(description) + whenever(it.timeoutMillis).thenReturn(timeoutMillis) + whenever(it.consumedMillis).thenReturn(consumedMillis) + } + + @Test + @Config(sdk = [37]) + fun `ANR warning listener adds a breadcrumb and is unregistered on close`() { + val activityManager = mock() + val sut = getSutWithActivityManager(activityManager) + + sut.register(fixture.scopes, fixture.options) + + val consumerCaptor = argumentCaptor>() + verify(activityManager).registerAnrWarningListener(any(), consumerCaptor.capture()) + + consumerCaptor.firstValue.accept(anrWarningResult()) + + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("system", it.type) + assertEquals("app.anr", it.category) + assertEquals(SentryLevel.WARNING, it.level) + assertEquals("ANR warning", it.message) + assertEquals("broadcast_of_intent", it.getData("anr_type")) + assertEquals(7, it.getData("anr_id")) + assertEquals(10_000L, it.getData("timeout_ms")) + assertEquals(7_500L, it.getData("consumed_ms")) + assertEquals( + "Broadcast of Intent { act=android.intent.action.SCREEN_ON }", + it.getData("description"), + ) + } + ) + + sut.close() + + verify(activityManager).unregisterAnrWarningListener(consumerCaptor.firstValue) + } + + @Test + @Config(sdk = [37]) + fun `unknown ANR types are reported verbatim instead of being dropped`() { + val activityManager = mock() + val sut = getSutWithActivityManager(activityManager) + + sut.register(fixture.scopes, fixture.options) + + val consumerCaptor = argumentCaptor>() + verify(activityManager).registerAnrWarningListener(any(), consumerCaptor.capture()) + consumerCaptor.firstValue.accept(anrWarningResult(anrType = 42)) + + verify(fixture.scopes) + .addBreadcrumb(check { assertEquals("unknown (42)", it.getData("anr_type")) }) + } + + @Test + @Config(sdk = [37]) + fun `ANR warning listener is not registered when app lifecycle breadcrumbs are disabled`() { + val activityManager = mock() + val sut = getSutWithActivityManager(activityManager) + fixture.options.isEnableAppLifecycleBreadcrumbs = false + + sut.register(fixture.scopes, fixture.options) + + verify(activityManager, never()).registerAnrWarningListener(any(), any()) + } + + @Test + @Config(sdk = [37]) + fun `ANR warning listener is not registered below API 37`() { + val activityManager = mock() + val sut = getSutWithActivityManager(activityManager, sdkVersion = 36) + + sut.register(fixture.scopes, fixture.options) + + verify(activityManager, never()).registerAnrWarningListener(any(), any()) + } + + @Test + @Config(sdk = [37]) + fun `a failure to register the ANR warning listener still installs the integration`() { + val activityManager = mock() + whenever(activityManager.registerAnrWarningListener(any(), any())) + .thenThrow(RuntimeException("binder died")) + val sut = getSutWithActivityManager(activityManager) + + sut.register(fixture.scopes, fixture.options) + + assertNotNull(sut.watcher) + + sut.close() + + verify(activityManager, never()).unregisterAnrWarningListener(any()) + } + + @Test + @Config(sdk = [37]) + fun `closing twice unregisters the ANR warning listener only once`() { + val activityManager = mock() + val sut = getSutWithActivityManager(activityManager) + + sut.register(fixture.scopes, fixture.options) + sut.close() + sut.close() + + verify(activityManager, times(1)).unregisterAnrWarningListener(any()) + } + @Test fun `When AppLifecycleIntegration is added, lifecycle watcher should be started`() { val sut = fixture.getSut() @@ -97,4 +257,113 @@ class AppLifecycleIntegrationTest { // Verify that lifecycleObserver is null after unregistering assertNull(appState.lifecycleObserver) } + + @Test + fun `ANR type names are mapped`() { + assertEquals("other", AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_OTHER)) + assertEquals( + "input_dispatch_no_focused_window", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_INPUT_DISPATCH_NO_FOCUSED_WINDOW), + ) + assertEquals( + "broadcast_of_intent", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_BROADCAST_OF_INTENT), + ) + assertEquals( + "start_foreground_service", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_START_FOREGROUND_SERVICE), + ) + assertEquals( + "execute_service", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_EXECUTE_SERVICE), + ) + assertEquals( + "content_provider_not_responding", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_CONTENT_PROVIDER_NOT_RESPONDING), + ) + assertEquals( + "app_triggered", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_APP_TRIGGERED), + ) + assertEquals( + "foreground_short_service_timeout", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_FOREGROUND_SHORT_SERVICE_TIMEOUT), + ) + assertEquals( + "job_service_start", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_JOB_SERVICE_START), + ) + assertEquals( + "application_start", + AppLifecycleIntegration.getAnrTypeName(AnrTypes.ANR_TYPE_APPLICATION_START), + ) + assertEquals("unknown (42)", AppLifecycleIntegration.getAnrTypeName(42)) + } + + @Test + @Config(sdk = [37]) + fun `the binder calls run on the executor service, not on the calling thread`() { + val activityManager = mock() + val executorService = DeferredExecutorService() + val sut = getSutWithActivityManager(activityManager, executorService = executorService) + + sut.register(fixture.scopes, fixture.options) + verify(activityManager, never()).registerAnrWarningListener(any(), any()) + + executorService.runAll() + verify(activityManager).registerAnrWarningListener(any(), any()) + + sut.close() + verify(activityManager, never()).unregisterAnrWarningListener(any()) + + executorService.runAll() + verify(activityManager).unregisterAnrWarningListener(any()) + } + + @Test + @Config(sdk = [37]) + fun `a register task still queued when close runs does not register the listener`() { + val activityManager = mock() + val executorService = DeferredExecutorService() + val sut = getSutWithActivityManager(activityManager, executorService = executorService) + + sut.register(fixture.scopes, fixture.options) + sut.close() + executorService.runAll() + + verify(activityManager, never()).registerAnrWarningListener(any(), any()) + verify(activityManager, never()).unregisterAnrWarningListener(any()) + } + + @Test + @Config(sdk = [37]) + fun `a dropped register task leaves the listener uninstalled`() { + val activityManager = mock() + val sut = + getSutWithActivityManager( + activityManager, + executorService = NonOverridableNoOpSentryExecutorService(), + ) + + sut.register(fixture.scopes, fixture.options) + + verify(activityManager, never()).registerAnrWarningListener(any(), any()) + assertNotNull(sut.watcher) + } + + @Test + @Config(sdk = [37]) + fun `close unregisters inline when the executor service drops the task`() { + val activityManager = mock() + val sut = getSutWithActivityManager(activityManager) + + sut.register(fixture.scopes, fixture.options) + verify(activityManager).registerAnrWarningListener(any(), any()) + + // The executor is typically already shut down by the time integrations are closed. + fixture.options.executorService = NonOverridableNoOpSentryExecutorService() + sut.close() + + verify(activityManager).unregisterAnrWarningListener(any()) + } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/AnrWarningBreadcrumbTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/AnrWarningBreadcrumbTest.kt new file mode 100644 index 00000000000..3a845558c53 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/AnrWarningBreadcrumbTest.kt @@ -0,0 +1,85 @@ +package io.sentry.uitest.android + +import android.content.Intent +import android.os.Build +import androidx.test.core.app.launchActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroidOptions +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AnrWarningBreadcrumbTest : BaseUiTest() { + @AfterTest + fun resetTimeout() { + runCommand("device_config delete activity_manager $TIMEOUT_KEY") + } + + @Test + fun anrWarningIsRecordedAsBreadcrumb() { + assumeTrue( + "ActivityManager.registerAnrWarningListener needs API 37", + Build.VERSION.SDK_INT >= 37, + ) + + // The grace period for calling startForeground() is device-tunable, so shrink it to keep the + // test + // fast. The warning lands at half of whatever this is set to. + runCommand("device_config put activity_manager $TIMEOUT_KEY $TIMEOUT_MS") + + // beforeBreadcrumb sees the breadcrumb on the thread that records it, so there is no need to + // capture an event and round-trip it through the relay just to assert on it. + val recorded = CountDownLatch(1) + val anrBreadcrumb = AtomicReference() + initSentry { options: SentryAndroidOptions -> + options.isEnableAppLifecycleBreadcrumbs = true + options.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + if (breadcrumb.category == "app.anr") { + anrBreadcrumb.set(breadcrumb) + recorded.countDown() + } + breadcrumb + } + } + + // startForegroundService is only allowed from the foreground. + launchActivity() + + // SlowStartService never calls startForeground(). It waits past the warning at 50% of the grace + // period, then stops itself before the 100% deadline, where the system would otherwise throw + // ForegroundServiceDidNotStartInTimeException. The main thread stays responsive throughout. + context.startForegroundService( + Intent(context, SlowStartService::class.java) + .putExtra(SlowStartService.EXTRA_STOP_SELF_AFTER_MILLIS, STOP_SELF_AFTER_MS) + ) + + val arrived = recorded.await(10, TimeUnit.SECONDS) + assertTrue(arrived, "No ANR warning within 10s") + + val breadcrumb = anrBreadcrumb.get() + assertNotNull(breadcrumb) + assertEquals("system", breadcrumb.type) + assertEquals("app.anr", breadcrumb.category) + assertEquals("ANR warning", breadcrumb.message) + assertEquals("start_foreground_service", breadcrumb.getData("anr_type")) + assertNotNull(breadcrumb.getData("anr_id")) + assertNotNull(breadcrumb.getData("consumed_ms")) + } + + private companion object { + const val TIMEOUT_KEY = "service_start_foreground_timeout_ms" + const val TIMEOUT_MS = 4_000 + // Comfortably after the warning at 50% of TIMEOUT_MS, comfortably before the deadline at 100%. + const val STOP_SELF_AFTER_MS = 2_800L + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index 62be835eb2b..9eca1839226 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -50,7 +50,7 @@ abstract class BaseUiTest { /** Mock relay server that receives all envelopes sent during the test. */ protected val relay = MockRelay(false, relayIdlingResource) - private fun runCommand(cmd: String) { + protected fun runCommand(cmd: String) { val automation = InstrumentationRegistry.getInstrumentation().uiAutomation val pfd = automation.executeShellCommand(cmd) try { diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml index 6bcad62e6de..410133386f6 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/SlowStartService.kt b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/SlowStartService.kt new file mode 100644 index 00000000000..3c4829c5ed1 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/SlowStartService.kt @@ -0,0 +1,29 @@ +package io.sentry.uitest.android + +import android.app.Service +import android.content.Intent +import android.os.Handler +import android.os.IBinder +import android.os.Looper + +/** + * Starts as a foreground service but never calls [startForeground], so the system emits an ANR + * warning at half of the `service_start_foreground_timeout_ms` grace period. It then stops itself + * before the grace period expires, because at 100% the system throws + * ForegroundServiceDidNotStartInTimeException and kills the app. + * + * Used by `AnrWarningBreadcrumbTest` to provoke an ANR warning without stalling the main thread. + */ +class SlowStartService : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val stopSelfAfterMillis = intent?.getLongExtra(EXTRA_STOP_SELF_AFTER_MILLIS, 0L) ?: 0L + Handler(Looper.getMainLooper()).postDelayed({ stopSelf() }, stopSelfAfterMillis) + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + const val EXTRA_STOP_SELF_AFTER_MILLIS = "stop_self_after_millis" + } +} From 7f86c34ca03a0e3fe705a417c99dc432216c86f3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 12 Oct 2026 10:14:25 +0200 Subject: [PATCH 2/3] docs(changelog): Add entry for ANR warning breadcrumbs --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0036c65807e..adf875daa84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Use `SentryNavEffect` to record navigation transactions, breadcrumbs, screen names, and additional context as your nav back stack changes. - See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details. - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) +- Record ANR warnings from the system as `app.anr` breadcrumbs on Android 17 (API 37) and above ([#6245](https://github.com/getsentry/sentry-java/pull/6245)) + - The breadcrumb contains the ANR type, the system description, the timeout, and how much of the timeout was already used. It is recorded when `enableAppLifecycleBreadcrumbs` is enabled. ### Dependencies From 18a35049cf39252e956b4502293bbfd581e8fa85 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 12 Oct 2026 10:21:33 +0200 Subject: [PATCH 3/3] fix(android): Keep the no-arg AppLifecycleIntegration constructor as deprecated --- .../api/sentry-android-core.api | 1 + .../android/core/AppLifecycleIntegration.java | 33 ++++++++++++++++--- .../core/AppLifecycleIntegrationTest.kt | 14 ++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index b17cb212183..cd46f0ce8c3 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -180,6 +180,7 @@ public final class io/sentry/android/core/AppComponentsBreadcrumbsIntegration : } public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/Integration, java/io/Closeable { + public fun ()V public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V public fun close ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 18ced337040..1a3d07317f0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -43,11 +43,22 @@ public final class AppLifecycleIntegration implements Integration, Closeable { private @Nullable SentryAndroidOptions options; - private final @NotNull Context context; - private final @NotNull BuildInfoProvider buildInfoProvider; + // Both null when created through the deprecated no-arg constructor, which skips ANR warnings. + private final @Nullable Context context; + private final @Nullable BuildInfoProvider buildInfoProvider; private @Nullable Consumer anrWarningConsumer; private boolean isClosed; + /** + * @deprecated Without a {@link Context} the integration cannot listen for ANR warnings. Use + * {@link #AppLifecycleIntegration(Context, BuildInfoProvider)} instead. + */ + @Deprecated + public AppLifecycleIntegration() { + this.context = null; + this.buildInfoProvider = null; + } + public AppLifecycleIntegration( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { this.context = context.getApplicationContext(); @@ -128,6 +139,13 @@ private void removeObservers() { */ private void scheduleRegisterAnrWarningListener( final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + final @Nullable Context context = this.context; + if (context == null || buildInfoProvider == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "ANR warning listener needs a Context, not installed."); + return; + } if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.CINNAMON_BUN) { options .getLogger() @@ -136,7 +154,9 @@ private void scheduleRegisterAnrWarningListener( } try { - options.getExecutorService().submit(() -> registerAnrWarningListener(scopes, options)); + options + .getExecutorService() + .submit(() -> registerAnrWarningListener(context, scopes, options)); } catch (RejectedExecutionException e) { options .getLogger() @@ -171,7 +191,9 @@ private void scheduleUnregisterAnrWarningListener() { // before this ever runs. The suppression is scoped so that register() stays lint-checked. @SuppressLint("NewApi") private void registerAnrWarningListener( - final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + final @NotNull Context context, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { final @Nullable ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); if (activityManager == null) { @@ -216,7 +238,8 @@ private void unregisterAnrWarningListener(final @NotNull SentryAndroidOptions op anrWarningConsumer = null; } - if (consumerRef == null) { + final @Nullable Context context = this.context; + if (consumerRef == null || context == null) { return; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt index 505e5d84ffc..30db5e14cb7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt @@ -143,6 +143,20 @@ class AppLifecycleIntegrationTest { verify(activityManager, never()).registerAnrWarningListener(any(), any()) } + @Test + @Config(sdk = [37]) + fun `the deprecated no-arg constructor installs the integration without the ANR warning listener`() { + @Suppress("DEPRECATION") val sut = AppLifecycleIntegration() + + sut.register(fixture.scopes, fixture.options) + + assertNotNull(sut.watcher) + + sut.close() + + assertNull(sut.watcher) + } + @Test @Config(sdk = [37]) fun `ANR warning listener is not registered below API 37`() {