diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index abcca4f883..918acc385f 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -86,6 +86,9 @@ dependencies { compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) compileOnly(projects.sentryAndroidDistribution) + // Used at runtime by AnrIntegration's heartbeat watchdog to capture native stacks via the NDK + // companion. Optional at runtime - sentry-android-ndk provides it transitively when present. + compileOnly(libs.sentry.native.ndk) // lifecycle processor, session tracking implementation(libs.androidx.lifecycle.common.java8) 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 5704cf7d7d..a1cbbdc69d 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 @@ -401,6 +401,12 @@ static void installDefaultIntegrations( // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); + // Heartbeat-mode app-hang detection for non-Looper main threads (e.g. Unity / Unreal). + // Self-gates on SentryAndroidOptions.anrThreadId == 0 at register() time, so it's harmless + // to install unconditionally here. We can't gate on the option value yet because user + // configuration runs after this method. + options.addIntegration(new AnrHeartbeatIntegration(context)); + options.addIntegration(new AnrProfilingIntegration()); // registerActivityLifecycleCallbacks is only available if Context is an AppContext diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrHeartbeatIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrHeartbeatIntegration.java new file mode 100644 index 0000000000..61aac4689f --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrHeartbeatIntegration.java @@ -0,0 +1,334 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Debug; +import io.sentry.AnrHeartbeatRegistry; +import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Integration; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.SentryStackFrame; +import io.sentry.protocol.SentryStackTrace; +import io.sentry.protocol.SentryThread; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.HintUtils; +import io.sentry.util.Objects; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.File; +import java.io.FileReader; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +/** + * Heartbeat-based app-hang detection for runtimes whose main thread is not an Android Looper thread + * (e.g. Unity, Unreal). The host runtime calls {@link io.sentry.Sentry#notifyAnrThreadAlive()} + * regularly from the monitored thread; if no heartbeat arrives within {@link + * SentryAndroidOptions#getAnrTimeoutIntervalMillis()}, an ANR event is reported with the captured + * native stack of the monitored thread (when the NDK companion is available). + * + *

Self-gates on {@code SentryAndroidOptions.anrThreadId == 0} at register time, so installing + * this integration unconditionally is safe. + * + *

Orthogonal to {@link AnrIntegration} (Looper probe) and {@link AnrV2Integration} ({@code + * ApplicationExitInfo}): all three can coexist because they monitor different signals. + */ +public final class AnrHeartbeatIntegration implements Integration, Closeable { + + /** Polling cadence of the watchdog thread, in ms. Independent of the heartbeat cadence. */ + static final long POLLING_INTERVAL_MS = 500L; + + private final @NotNull Context context; + + @SuppressLint("StaticFieldLeak") + @Nullable + private static volatile HeartbeatWatchDog watchdog; + + private static final @NotNull AutoClosableReentrantLock watchdogLock = + new AutoClosableReentrantLock(); + + @Nullable private SentryOptions options; + + public AnrHeartbeatIntegration(final @NotNull Context context) { + this.context = ContextUtils.getApplicationContext(context); + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + this.options = Objects.requireNonNull(options, "SentryOptions is required"); + final SentryAndroidOptions androidOptions = (SentryAndroidOptions) options; + final ILogger logger = androidOptions.getLogger(); + + final long anrThreadId = androidOptions.getAnrThreadId(); + if (anrThreadId == 0) { + logger.log( + SentryLevel.DEBUG, + "AnrHeartbeatIntegration disabled: SentryAndroidOptions.anrThreadId is not set."); + return; + } + + if (!androidOptions.isAnrEnabled()) { + logger.log(SentryLevel.DEBUG, "AnrHeartbeatIntegration disabled: ANR detection is off."); + return; + } + + try (final @NotNull ISentryLifecycleToken ignored = watchdogLock.acquire()) { + if (watchdog != null) { + logger.log(SentryLevel.DEBUG, "AnrHeartbeatIntegration already installed; skipping."); + return; + } + + // Resolve the monitored thread name once at register time. Falls back to a generic name + // if /proc is unreadable, which is rare on real devices. + final @Nullable String threadName = readThreadName(anrThreadId); + + final HeartbeatWatchDog wd = + new HeartbeatWatchDog( + androidOptions.getAnrTimeoutIntervalMillis(), + POLLING_INTERVAL_MS, + androidOptions.isAnrReportInDebug(), + error -> reportAnr(scopes, androidOptions, anrThreadId, threadName, error), + logger); + wd.start(); + watchdog = wd; + AnrHeartbeatRegistry.setListener(wd::notifyAlive); + addIntegrationToSdkVersion("AnrHeartbeat"); + + logger.log( + SentryLevel.DEBUG, + "AnrHeartbeatIntegration installed (tid=%d, timeout=%d ms, thread=%s).", + anrThreadId, + androidOptions.getAnrTimeoutIntervalMillis(), + threadName); + } + } + + @Override + public void close() { + try (final @NotNull ISentryLifecycleToken ignored = watchdogLock.acquire()) { + AnrHeartbeatRegistry.setListener(null); + if (watchdog != null) { + watchdog.interrupt(); + watchdog = null; + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "AnrHeartbeatIntegration removed."); + } + } + } + } + + @TestOnly + @Nullable + HeartbeatWatchDog getWatchdog() { + return watchdog; + } + + private void reportAnr( + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options, + final long tid, + final @Nullable String threadName, + final @NotNull ApplicationNotResponding error) { + options.getLogger().log(SentryLevel.INFO, "ANR triggered with message: %s", error.getMessage()); + + final boolean isAppInBackground = Boolean.TRUE.equals(AppState.getInstance().isInBackground()); + + String message = "ANR for at least " + options.getAnrTimeoutIntervalMillis() + " ms."; + if (isAppInBackground) { + message = "Background " + message; + } + final ApplicationNotResponding wrapped = new ApplicationNotResponding(message); + final Mechanism mechanism = new Mechanism(); + mechanism.setType("ANR"); + // The watchdog thread is not the culprit — let event processors prefer the monitored thread. + final Throwable throwable = new ExceptionMechanismException(mechanism, wrapped, null, true); + + final SentryEvent event = new SentryEvent(throwable); + event.setLevel(SentryLevel.ERROR); + + attachNativeStack(event, tid, threadName, options); + + final AnrIntegration.AnrHint anrHint = new AnrIntegration.AnrHint(isAppInBackground); + final Hint hint = HintUtils.createWithTypeCheckHint(anrHint); + scopes.captureEvent(event, hint); + } + + private void attachNativeStack( + final @NotNull SentryEvent event, + final long tid, + final @Nullable String threadName, + final @NotNull SentryAndroidOptions options) { + try { + final long[] addresses = io.sentry.ndk.SentryNdk.captureThreadStack(tid); + if (addresses.length == 0) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Captured 0 native frames for thread %d; skipping native stack attachment.", + tid); + return; + } + + final List frames = new ArrayList<>(addresses.length); + final Set addressSet = new HashSet<>(addresses.length); + // Sentry stack frames are ordered with the oldest caller first; native unwinders typically + // return frames with the most recent at index 0. Reverse on attach. + for (int i = addresses.length - 1; i >= 0; i--) { + final String addr = "0x" + Long.toHexString(addresses[i]); + final SentryStackFrame frame = new SentryStackFrame(); + frame.setInstructionAddr(addr); + // Mark each frame as native so the symbolicator resolves against the attached debug + // images (parent event platform stays "java"). + frame.setPlatform("native"); + frames.add(frame); + addressSet.add(addr); + } + + final IDebugImagesLoader debugImagesLoader = options.getDebugImagesLoader(); + final Set images = debugImagesLoader.loadDebugImagesForAddresses(addressSet); + if (images != null && !images.isEmpty()) { + DebugMeta debugMeta = event.getDebugMeta(); + if (debugMeta == null) { + debugMeta = new DebugMeta(); + event.setDebugMeta(debugMeta); + } + debugMeta.setImages(new ArrayList<>(images)); + } else { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No debug images matched the %d captured native frame addresses for ANR thread %d; " + + "frames will not symbolicate.", + addressSet.size(), + tid); + } + + final SentryStackTrace stacktrace = new SentryStackTrace(frames); + + final SentryThread thread = new SentryThread(); + thread.setName(threadName != null ? threadName : "anr-thread"); + thread.setId(tid); + thread.setCrashed(true); + thread.setStacktrace(stacktrace); + + final List threads = new ArrayList<>(); + threads.add(thread); + event.setThreads(threads); + } catch (Throwable t) { + // NoClassDefFoundError when sentry-native-ndk isn't on the runtime classpath, anything + // else if the unwinder itself fails — either way the ANR event still goes out, just + // without the native stack. + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to capture native stack for thread %d", tid); + } + } + + private static @Nullable String readThreadName(final long tid) { + final File commFile = new File("/proc/self/task/" + tid + "/comm"); + if (!commFile.exists()) { + return null; + } + try (final BufferedReader reader = new BufferedReader(new FileReader(commFile))) { + final String line = reader.readLine(); + return line != null ? line.trim() : null; + } catch (Throwable t) { + return null; + } + } + + /** + * Watchdog thread. Polls a heartbeat timestamp updated via {@link #notifyAlive()} and fires the + * listener when the monitored thread hasn't reported liveness within the configured timeout. + * Suppresses detection while the app is backgrounded (AppState-aware), keeping the timestamp + * fresh so the next foreground entry gets a full timeout window. + */ + static final class HeartbeatWatchDog extends Thread { + private final long timeoutMs; + private final long pollingIntervalMs; + private final boolean reportInDebug; + private final @NotNull ANRWatchDog.ANRListener listener; + private final @NotNull ILogger logger; + private volatile long lastHeartbeatNs; + private final AtomicBoolean reported = new AtomicBoolean(false); + + HeartbeatWatchDog( + final long timeoutMs, + final long pollingIntervalMs, + final boolean reportInDebug, + final @NotNull ANRWatchDog.ANRListener listener, + final @NotNull ILogger logger) { + super("|ANR-Heartbeat-WatchDog|"); + this.timeoutMs = timeoutMs; + this.pollingIntervalMs = pollingIntervalMs; + this.reportInDebug = reportInDebug; + this.listener = listener; + this.logger = logger; + this.lastHeartbeatNs = System.nanoTime(); + } + + void notifyAlive() { + lastHeartbeatNs = System.nanoTime(); + reported.set(false); + } + + @Override + public void run() { + while (!isInterrupted()) { + try { + Thread.sleep(pollingIntervalMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // While backgrounded the host runtime may not be driven (e.g. Unity's main thread is + // paused when the app has no surface). Skip detection and keep the timestamp fresh so + // the next foreground entry has a full timeout window. + if (Boolean.TRUE.equals(AppState.getInstance().isInBackground())) { + lastHeartbeatNs = System.nanoTime(); + continue; + } + + final long elapsedMs = (System.nanoTime() - lastHeartbeatNs) / 1_000_000L; + if (elapsedMs <= timeoutMs) { + continue; + } + + if (!reportInDebug && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) { + logger.log( + SentryLevel.DEBUG, + "ANR heartbeat timeout ignored because the debugger is connected."); + reported.set(true); + continue; + } + + if (reported.compareAndSet(false, true)) { + final ApplicationNotResponding error = + new ApplicationNotResponding( + "Application Not Responding for at least " + timeoutMs + " ms."); + listener.onAppNotResponding(error); + } + } + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 8fe702aad5..b4be8d7c99 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -37,6 +37,18 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable ANR on Debug mode Default is disabled Used by AnrIntegration */ private boolean anrReportInDebug = false; + /** + * Linux kernel thread ID (TID) to monitor for app hangs. When set to a non-zero value, the ANR + * watchdog switches from Looper-based detection to heartbeat-based detection: the application + * must regularly call {@link io.sentry.Sentry#notifyAnrThreadAlive()} from the target thread to + * indicate it is responsive. If no heartbeat is received within {@link + * #getAnrTimeoutIntervalMillis()}, an ANR event is reported. + * + *

Intended for runtimes whose main thread is not an Android Looper thread (e.g. Unity, + * Unreal). Set to 0 (default) to use the standard Looper-probe watchdog. + */ + private long anrThreadId = 0; + /** * Enable or disable automatic breadcrumbs for Activity lifecycle. Using * Application.ActivityLifecycleCallbacks @@ -330,6 +342,28 @@ public void setAnrReportInDebug(boolean anrReportInDebug) { this.anrReportInDebug = anrReportInDebug; } + /** + * Returns the Linux kernel thread ID (TID) monitored by the ANR watchdog in heartbeat mode. + * Default is 0 (Looper-probe mode). + * + * @return the TID or 0 if heartbeat mode is disabled + */ + public long getAnrThreadId() { + return anrThreadId; + } + + /** + * Sets the Linux kernel thread ID (TID) monitored by the ANR watchdog. When set to a non-zero + * value, the watchdog switches to heartbeat mode and expects {@link + * io.sentry.Sentry#notifyAnrThreadAlive()} to be called regularly from that thread. Default is 0 + * (Looper-probe mode). + * + * @param tid the TID or 0 to disable heartbeat mode + */ + public void setAnrThreadId(final long tid) { + this.anrThreadId = tid; + } + /** * Sets Tombstone reporting (ApplicationExitInfo.REASON_CRASH_NATIVE) to enabled or disabled. * diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrHeartbeatIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrHeartbeatIntegrationTest.kt new file mode 100644 index 0000000000..4f6249e010 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrHeartbeatIntegrationTest.kt @@ -0,0 +1,168 @@ +package io.sentry.android.core + +import android.content.Context +import io.sentry.AnrHeartbeatRegistry +import io.sentry.IScopes +import io.sentry.SentryLevel +import io.sentry.exception.ExceptionMechanismException +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify + +class AnrHeartbeatIntegrationTest { + private val context = mock() + private val scopes = mock() + + private fun options( + tid: Long = 0L, + timeoutMs: Long = 200L, + anrEnabled: Boolean = true, + reportInDebug: Boolean = true, + ): SentryAndroidOptions = + SentryAndroidOptions().apply { + setLogger(mock()) + anrTimeoutIntervalMillis = timeoutMs + isAnrEnabled = anrEnabled + isAnrReportInDebug = reportInDebug + anrThreadId = tid + } + + @BeforeTest + fun `before each test`() { + AnrHeartbeatIntegration(context).close() + AnrHeartbeatRegistry.setListener(null) + AppState.getInstance().resetInstance() + } + + @AfterTest + fun `after each test`() { + AnrHeartbeatIntegration(context).close() + AnrHeartbeatRegistry.setListener(null) + AppState.getInstance().resetInstance() + } + + @Test + fun `disabled when anrThreadId is zero`() { + val sut = AnrHeartbeatIntegration(context) + + sut.register(scopes, options(tid = 0L)) + + assertNull(sut.watchdog) + } + + @Test + fun `disabled when anr detection is off`() { + val sut = AnrHeartbeatIntegration(context) + + sut.register(scopes, options(tid = 42L, anrEnabled = false)) + + assertNull(sut.watchdog) + } + + @Test + fun `installed when anrThreadId is set`() { + val sut = AnrHeartbeatIntegration(context) + + sut.register(scopes, options(tid = 42L)) + + assertNotNull(sut.watchdog) + assertTrue(sut.watchdog!!.isAlive) + } + + @Test + fun `heartbeats suppress ANR`() { + val sut = AnrHeartbeatIntegration(context) + sut.register(scopes, options(tid = 42L, timeoutMs = 200L)) + + // Keep beating for 3x the timeout. No event should be captured. + val running = java.util.concurrent.atomic.AtomicBoolean(true) + val beater = Thread { + while (running.get()) { + AnrHeartbeatRegistry.notifyAlive() + Thread.sleep(25) + } + } + try { + beater.start() + Thread.sleep(700) + verify(scopes, never()).captureEvent(any(), any()) + } finally { + running.set(false) + beater.join(500) + } + } + + @Test + fun `no heartbeats fires ANR with native thread metadata`() { + val sut = AnrHeartbeatIntegration(context) + sut.register(scopes, options(tid = 42L, timeoutMs = 200L)) + + // Don't beat. The watchdog polls at POLLING_INTERVAL_MS and trips after timeoutMs. + val captor = argumentCaptor() + verify(scopes, timeout(3_000)).captureEvent(captor.capture(), any()) + + val event = captor.firstValue + // getThrowable() unwraps the ExceptionMechanismException; use throwableMechanism for the + // wrapper. + val mechanism = event.throwableMechanism + assertTrue(mechanism is ExceptionMechanismException) + assertNull((mechanism as ExceptionMechanismException).thread) // watchdog is not the culprit + assertNotNull(event.level) + assertTrue(event.level == SentryLevel.ERROR) + } + + @Test + fun `backgrounded app does not trip ANR even without heartbeats`() { + AppState.getInstance().setInBackground(true) + + val sut = AnrHeartbeatIntegration(context) + sut.register(scopes, options(tid = 42L, timeoutMs = 200L)) + + // Run for ~3x the timeout with no beats. Background gate must suppress detection. + Thread.sleep(700) + verify(scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `notifyAlive routes through the registry`() { + val sut = AnrHeartbeatIntegration(context) + sut.register(scopes, options(tid = 42L, timeoutMs = 200L)) + + val before = sut.watchdog!!.javaClass.getDeclaredField("lastHeartbeatNs") + before.isAccessible = true + val ts0 = before.getLong(sut.watchdog) + + Thread.sleep(15) + AnrHeartbeatRegistry.notifyAlive() + + val ts1 = before.getLong(sut.watchdog) + assertTrue(ts1 > ts0, "notifyAlive must update lastHeartbeatNs via the registry") + } + + @Test + fun `close stops watchdog and clears registry listener`() { + val sut = AnrHeartbeatIntegration(context) + sut.register(scopes, options(tid = 42L)) + val wd = sut.watchdog + assertNotNull(wd) + + sut.close() + + assertNull(sut.watchdog) + // The thread interrupt is asynchronous; give it a moment to wind down. + wd.join(1_000) + assertTrue(!wd.isAlive) + + // After close, notifyAlive must be a no-op (no listener registered). + AnrHeartbeatRegistry.notifyAlive() // would throw if listener wasn't cleared + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc..db939ba7b4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -143,6 +143,19 @@ class SentryAndroidOptionsTest { assertFalse(options.isEnableScopeSync) } + @Test + fun `anrThreadId defaults to 0 (Looper-probe mode)`() { + val sentryOptions = SentryAndroidOptions() + assertEquals(0L, sentryOptions.anrThreadId) + } + + @Test + fun `anrThreadId getter setter round-trip`() { + val sentryOptions = SentryAndroidOptions() + sentryOptions.anrThreadId = 4242L + assertEquals(4242L, sentryOptions.anrThreadId) + } + @Test fun `performance v2 is enabled by default`() { val sentryOptions = SentryAndroidOptions() diff --git a/sentry/src/main/java/io/sentry/AnrHeartbeatRegistry.java b/sentry/src/main/java/io/sentry/AnrHeartbeatRegistry.java new file mode 100644 index 0000000000..50d7bdd08f --- /dev/null +++ b/sentry/src/main/java/io/sentry/AnrHeartbeatRegistry.java @@ -0,0 +1,39 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * Internal entry point used by hybrid SDKs (e.g. Sentry for Unity, Unreal) to signal that the + * monitored thread is alive in heartbeat-mode ANR detection. The platform-specific watchdog lives + * in {@code sentry-android-core}, which the core {@code sentry} module cannot depend on — so the + * watchdog registers a {@link Runnable} listener here at integration init time, and callers + * dispatch through {@link #notifyAlive()}. + * + *

This class is internal SDK plumbing and not part of the public API. End-user app code should + * not call it directly. + */ +@ApiStatus.Internal +public final class AnrHeartbeatRegistry { + + private AnrHeartbeatRegistry() {} + + private static volatile @Nullable Runnable listener; + + /** + * Registers a heartbeat listener. Pass {@code null} to clear (e.g. on integration close). + * + * @param r the listener, or {@code null} to clear + */ + public static void setListener(final @Nullable Runnable r) { + listener = r; + } + + /** Notifies the registered listener, if any. A no-op if no listener has been registered. */ + public static void notifyAlive() { + final Runnable r = listener; + if (r != null) { + r.run(); + } + } +} diff --git a/sentry/src/test/java/io/sentry/AnrHeartbeatRegistryTest.kt b/sentry/src/test/java/io/sentry/AnrHeartbeatRegistryTest.kt new file mode 100644 index 0000000000..2434df6a0f --- /dev/null +++ b/sentry/src/test/java/io/sentry/AnrHeartbeatRegistryTest.kt @@ -0,0 +1,62 @@ +package io.sentry + +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class AnrHeartbeatRegistryTest { + + @AfterTest + fun tearDown() { + // Reset the static registry to avoid cross-test bleed. + AnrHeartbeatRegistry.setListener(null) + } + + @Test + fun `notifyAlive without a listener is a no-op`() { + // No setListener call - this must not throw. + AnrHeartbeatRegistry.notifyAlive() + } + + @Test + fun `notifyAlive invokes the registered listener`() { + val counter = AtomicInteger(0) + AnrHeartbeatRegistry.setListener({ counter.incrementAndGet() }) + + AnrHeartbeatRegistry.notifyAlive() + AnrHeartbeatRegistry.notifyAlive() + AnrHeartbeatRegistry.notifyAlive() + + assertEquals(3, counter.get()) + } + + @Test + fun `clearing the listener stops notifications`() { + val counter = AtomicInteger(0) + AnrHeartbeatRegistry.setListener({ counter.incrementAndGet() }) + AnrHeartbeatRegistry.notifyAlive() + assertEquals(1, counter.get()) + + AnrHeartbeatRegistry.setListener(null) + AnrHeartbeatRegistry.notifyAlive() + AnrHeartbeatRegistry.notifyAlive() + assertEquals(1, counter.get()) + } + + @Test + fun `setListener replaces the previous listener`() { + val firstCount = AtomicInteger(0) + val secondCount = AtomicInteger(0) + + AnrHeartbeatRegistry.setListener({ firstCount.incrementAndGet() }) + AnrHeartbeatRegistry.notifyAlive() + + AnrHeartbeatRegistry.setListener({ secondCount.incrementAndGet() }) + AnrHeartbeatRegistry.notifyAlive() + AnrHeartbeatRegistry.notifyAlive() + + assertEquals(1, firstCount.get()) + assertEquals(2, secondCount.get()) + } +}