From ed98d118cc49af2250fcfcac128ea709e5a93b0d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jan 2025 09:04:06 +0100 Subject: [PATCH 01/42] Various fixes to instrumentations running on the main thread (#4051) * Get rid of redundant requireNonNull * Do not instrument Window.Callback multiple times * Do not instrument FileIO if tracing is disabled * Do not traverse children if a touch event is not within view groups bounds * Add test for SentryFileOutputStream * Fix test * Fix test * Changelog * pr id * Fix api dump --- CHANGELOG.md | 8 +++ .../core/UserInteractionIntegration.java | 5 ++ .../AndroidViewGestureTargetLocator.java | 26 +++------- .../core/internal/gestures/ViewUtils.java | 36 +++++++++++-- .../core/UserInteractionIntegrationTest.kt | 24 +++++++++ .../SentryGestureListenerClickTest.kt | 22 ++++++-- .../gestures/ComposeGestureTargetLocator.java | 2 +- sentry/api/sentry.api | 1 + .../file/SentryFileInputStream.java | 27 +++++++--- .../file/SentryFileOutputStream.java | 45 +++++++++++++--- .../gestures/GestureTargetLocator.java | 3 +- .../file/SentryFileInputStreamTest.kt | 17 +++++-- .../file/SentryFileOutputStreamTest.kt | 51 ++++++++++++++++--- 13 files changed, 215 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3857217a987..208e9bf4c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) + ## 7.20.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index a0ad3591669..87e60fa6bfd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -50,6 +50,11 @@ private void startTracking(final @NotNull Activity activity) { delegate = new NoOpWindowCallback(); } + if (delegate instanceof SentryWindowCallback) { + // already instrumented + return; + } + final SentryGestureListener gestureListener = new SentryGestureListener(activity, hub, options); window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); 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 945ebeef649..f271c3da9e3 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 @@ -18,7 +18,6 @@ public final class AndroidViewGestureTargetLocator implements GestureTargetLocat private static final String ORIGIN = "old_view_system"; private final boolean isAndroidXAvailable; - private final int[] coordinates = new int[2]; public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; @@ -26,18 +25,16 @@ public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { if (!(root instanceof View)) { return null; } final View view = (View) root; - if (touchWithinBounds(view, x, y)) { - if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { - return createUiElement(view); - } else if (targetType == UiElement.Type.SCROLLABLE - && isViewScrollable(view, isAndroidXAvailable)) { - return createUiElement(view); - } + if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { + return createUiElement(view); + } else if (targetType == UiElement.Type.SCROLLABLE + && isViewScrollable(view, isAndroidXAvailable)) { + return createUiElement(view); } return null; } @@ -52,17 +49,6 @@ private UiElement createUiElement(final @NotNull View targetView) { } } - private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); - } - private static boolean isViewTappable(final @NotNull View view) { return view.isClickable() && view.getVisibility() == View.VISIBLE; } 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 6e7dab2ef5a..8aa8e89d694 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 @@ -7,7 +7,6 @@ import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import io.sentry.util.Objects; import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,6 +16,32 @@ @ApiStatus.Internal public final class ViewUtils { + private static final int[] coordinates = new int[2]; + + /** + * Verifies if the given touch coordinates are within the bounds of the given view. + * + * @param view the view to check if the touch coordinates are within its bounds + * @param x - the x coordinate of a {@link MotionEvent} + * @param y - the y coordinate of {@link MotionEvent} + * @return true if the touch coordinates are within the bounds of the view, false otherwise + */ + private static boolean touchWithinBounds( + final @Nullable View view, final float x, final float y) { + if (view == null) { + return false; + } + + view.getLocationOnScreen(coordinates); + int vx = coordinates[0]; + int vy = coordinates[1]; + + int w = view.getWidth(); + int h = view.getHeight(); + + return !(x < vx || x > vx + w || y < vy || y > vy + h); + } + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. @@ -40,7 +65,12 @@ public final class ViewUtils { @Nullable UiElement target = null; while (queue.size() > 0) { - final View view = Objects.requireNonNull(queue.poll(), "view is required"); + final View view = queue.poll(); + + if (!touchWithinBounds(view, x, y)) { + // if the touch is not hitting the view, skip traversal of its children + continue; + } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; @@ -54,7 +84,7 @@ public final class ViewUtils { if (newTarget != null) { if (targetType == UiElement.Type.CLICKABLE) { target = newTarget; - } else { + } else if (targetType == UiElement.Type.SCROLLABLE) { return newTarget; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index d43dfe14197..f126e6c9229 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -140,6 +140,7 @@ class UserInteractionIntegrationTest { ) ) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(fixture.window).callback = null @@ -160,6 +161,7 @@ class UserInteractionIntegrationTest { ) ) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(fixture.window).callback = delegate @@ -170,8 +172,30 @@ class UserInteractionIntegrationTest { val callback = mock() val sut = fixture.getSut(callback) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(callback).stopTracking() } + + @Test + fun `does not instrument if the callback is already ours`() { + val delegate = mock() + val context = mock() + val resources = Fixture.mockResources() + whenever(context.resources).thenReturn(resources) + val existingCallback = SentryWindowCallback( + delegate, + context, + mock(), + mock() + ) + val sut = fixture.getSut(existingCallback) + + sut.register(fixture.hub, fixture.options) + sut.onActivityResumed(fixture.activity) + + val argumentCaptor = argumentCaptor() + verify(fixture.window, never()).callback = argumentCaptor.capture() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index 1e6652276a7..c864e3d4b57 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -185,7 +185,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -214,7 +214,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -223,7 +223,7 @@ class SentryGestureListenerClickTest { val event = mock() val sut = fixture.getSut(event, attachViewsToRoot = false) - fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + fixture.window.mockDecorView(event = event, touchWithinBounds = true) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(fixture.target) } @@ -244,7 +244,7 @@ class SentryGestureListenerClickTest { val event = mock() val sut = fixture.getSut(event, attachViewsToRoot = false) - fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + fixture.window.mockDecorView(event = event, touchWithinBounds = true) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(fixture.target) } @@ -253,4 +253,18 @@ class SentryGestureListenerClickTest { verify(fixture.scope).propagationContext = any() } + + @Test + fun `if touch is not within view group bounds does not traverse its children`() { + val event = mock() + val sut = fixture.getSut(event, attachViewsToRoot = false) + fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(fixture.target) + } + + sut.onSingleTapUp(event) + + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + } } diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java index aaf085f4841..99cc5414419 100644 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -39,7 +39,7 @@ public ComposeGestureTargetLocator(final @NotNull ILogger logger) { @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { // lazy init composeHelper as it's using some reflection under the hood if (composeHelper == null) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c38d11e945f..b7cb1adfc7e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3645,6 +3645,7 @@ public final class io/sentry/instrumentation/file/SentryFileOutputStream : java/ public final class io/sentry/instrumentation/file/SentryFileOutputStream$Factory { public fun ()V public static fun create (Ljava/io/FileOutputStream;Ljava/io/File;)Ljava/io/FileOutputStream; + public static fun create (Ljava/io/FileOutputStream;Ljava/io/File;Lio/sentry/IHub;)Ljava/io/FileOutputStream; public static fun create (Ljava/io/FileOutputStream;Ljava/io/File;Z)Ljava/io/FileOutputStream; public static fun create (Ljava/io/FileOutputStream;Ljava/io/FileDescriptor;)Ljava/io/FileOutputStream; public static fun create (Ljava/io/FileOutputStream;Ljava/lang/String;)Ljava/io/FileOutputStream; diff --git a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java index 04bb87ae7c2..e1b46276bcf 100644 --- a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java +++ b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java @@ -3,6 +3,7 @@ import io.sentry.HubAdapter; import io.sentry.IHub; import io.sentry.ISpan; +import io.sentry.SentryOptions; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; @@ -127,26 +128,40 @@ public static final class Factory { public static FileInputStream create( final @NotNull FileInputStream delegate, final @Nullable String name) throws FileNotFoundException { - return new SentryFileInputStream( - init(name != null ? new File(name) : null, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(name != null ? new File(name) : null, delegate, hub)) + : delegate; } public static FileInputStream create( final @NotNull FileInputStream delegate, final @Nullable File file) throws FileNotFoundException { - return new SentryFileInputStream(init(file, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(file, delegate, hub)) + : delegate; } public static FileInputStream create( final @NotNull FileInputStream delegate, final @NotNull FileDescriptor descriptor) { - return new SentryFileInputStream( - init(descriptor, delegate, HubAdapter.getInstance()), descriptor); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(descriptor, delegate, hub), descriptor) + : delegate; } static FileInputStream create( final @NotNull FileInputStream delegate, final @Nullable File file, final @NotNull IHub hub) throws FileNotFoundException { - return new SentryFileInputStream(init(file, delegate, hub)); + return isTracingEnabled(hub) + ? new SentryFileInputStream(init(file, delegate, hub)) + : delegate; + } + + private static boolean isTracingEnabled(final @NotNull IHub hub) { + final @NotNull SentryOptions options = hub.getOptions(); + return options.isTracingEnabled(); } } } diff --git a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java index 9424710d71d..850c2216467 100644 --- a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java +++ b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java @@ -3,6 +3,7 @@ import io.sentry.HubAdapter; import io.sentry.IHub; import io.sentry.ISpan; +import io.sentry.SentryOptions; import java.io.File; import java.io.FileDescriptor; import java.io.FileNotFoundException; @@ -131,32 +132,62 @@ public static final class Factory { public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable String name) throws FileNotFoundException { - return new SentryFileOutputStream( - init(name != null ? new File(name) : null, false, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream( + init(name != null ? new File(name) : null, false, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable String name, final boolean append) throws FileNotFoundException { - return new SentryFileOutputStream( - init(name != null ? new File(name) : null, append, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream( + init(name != null ? new File(name) : null, append, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable File file) throws FileNotFoundException { - return new SentryFileOutputStream(init(file, false, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(file, false, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @Nullable File file, final boolean append) throws FileNotFoundException { - return new SentryFileOutputStream(init(file, append, delegate, HubAdapter.getInstance())); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(file, append, delegate, hub)) + : delegate; } public static FileOutputStream create( final @NotNull FileOutputStream delegate, final @NotNull FileDescriptor fdObj) { - return new SentryFileOutputStream(init(fdObj, delegate, HubAdapter.getInstance()), fdObj); + final @NotNull IHub hub = HubAdapter.getInstance(); + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(fdObj, delegate, hub), fdObj) + : delegate; + } + + public static FileOutputStream create( + final @NotNull FileOutputStream delegate, + final @Nullable File file, + final @NotNull IHub hub) + throws FileNotFoundException { + return isTracingEnabled(hub) + ? new SentryFileOutputStream(init(file, false, delegate, hub)) + : delegate; + } + + private static boolean isTracingEnabled(final @NotNull IHub hub) { + final @NotNull SentryOptions options = hub.getOptions(); + return options.isTracingEnabled(); } } } diff --git a/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java index 79109f70ff6..3fbfa1ab980 100644 --- a/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java +++ b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java @@ -1,11 +1,10 @@ package io.sentry.internal.gestures; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public interface GestureTargetLocator { @Nullable UiElement locate( - final @NotNull Object root, final float x, final float y, final UiElement.Type targetType); + final @Nullable Object root, final float x, final float y, final UiElement.Type targetType); } diff --git a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt index 5e27eb451d3..5bc2290eb0a 100644 --- a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt +++ b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt @@ -60,8 +60,10 @@ class SentryFileInputStreamTest { internal fun getSut( tmpFile: File? = null, - delegate: FileInputStream - ): SentryFileInputStream { + delegate: FileInputStream, + tracesSampleRate: Double? = 1.0 + ): FileInputStream { + options.tracesSampleRate = tracesSampleRate whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) whenever(hub.span).thenReturn(sentryTracer) @@ -69,7 +71,7 @@ class SentryFileInputStreamTest { delegate, tmpFile, hub - ) as SentryFileInputStream + ) } } @@ -242,6 +244,15 @@ class SentryFileInputStreamTest { assertEquals(false, fileIOSpan.data[SpanDataConvention.BLOCKED_MAIN_THREAD_KEY]) assertNull(fileIOSpan.data[SpanDataConvention.CALL_STACK_KEY]) } + + @Test + fun `when tracing is disabled does not instrument the stream`() { + val file = tmpFile + val delegate = ThrowingFileInputStream(file) + val stream = fixture.getSut(file, delegate = delegate, tracesSampleRate = null) + + assertTrue { stream is ThrowingFileInputStream } + } } class ThrowingFileInputStream(file: File) : FileInputStream(file) { diff --git a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt index f6a09830c26..95533f0f8b7 100644 --- a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt +++ b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt @@ -14,6 +14,8 @@ import org.junit.rules.TemporaryFolder import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.io.File +import java.io.FileOutputStream +import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread import kotlin.test.Test @@ -25,6 +27,7 @@ import kotlin.test.assertTrue class SentryFileOutputStreamTest { class Fixture { val hub = mock() + val options = SentryOptions() lateinit var sentryTracer: SentryTracer internal fun getSut( @@ -32,18 +35,33 @@ class SentryFileOutputStreamTest { activeTransaction: Boolean = true, append: Boolean = false ): SentryFileOutputStream { - whenever(hub.options).thenReturn( - SentryOptions().apply { - mainThreadChecker = MainThreadChecker.getInstance() - addInAppInclude("org.junit") - } - ) + options.run { + mainThreadChecker = MainThreadChecker.getInstance() + addInAppInclude("org.junit") + } + whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) if (activeTransaction) { whenever(hub.span).thenReturn(sentryTracer) } return SentryFileOutputStream(tmpFile, append, hub) } + + internal fun getSut( + tmpFile: File? = null, + delegate: FileOutputStream, + tracesSampleRate: Double? = 1.0 + ): FileOutputStream { + options.tracesSampleRate = tracesSampleRate + whenever(hub.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + whenever(hub.span).thenReturn(sentryTracer) + return SentryFileOutputStream.Factory.create( + delegate, + tmpFile, + hub + ) + } } @get:Rule @@ -157,4 +175,25 @@ class SentryFileOutputStreamTest { assertEquals(false, fileIOSpan.data[SpanDataConvention.BLOCKED_MAIN_THREAD_KEY]) assertNull(fileIOSpan.data[SpanDataConvention.CALL_STACK_KEY]) } + + @Test + fun `when tracing is disabled does not instrument the stream`() { + val file = tmpFile + val delegate = ThrowingFileOutputStream(file) + val stream = fixture.getSut(file, delegate = delegate, tracesSampleRate = null) + + assertTrue { stream is ThrowingFileOutputStream } + } +} + +class ThrowingFileOutputStream(file: File) : FileOutputStream(file) { + val throwable = IOException("Oops!") + + override fun write(b: Int) { + throw throwable + } + + override fun close() { + throw throwable + } } From e5095039e5cfb9c68ff89ab6fa1bcf8932731b36 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 16 Jan 2025 22:38:05 +0100 Subject: [PATCH 02/42] Fix BroadcastReceivers (#4052) * Drop TempSesnorBreadcrumbIntegration * Drop PhoneStateBreadcrumbsIntegration * Reduce number of system events we're listening to and use RECEIVER_NOT_EXPORTED * Format code * Changelog * Update CHANGELOG.md Co-authored-by: Stefano * Update CHANGELOG.md Co-authored-by: Stefano --------- Co-authored-by: Sentry Github Bot Co-authored-by: Stefano --- CHANGELOG.md | 18 +++ .../api/sentry-android-core.api | 15 +- .../core/AndroidOptionsInitializer.java | 2 - .../io/sentry/android/core/ContextUtils.java | 3 +- .../PhoneStateBreadcrumbsIntegration.java | 136 ----------------- .../SystemEventsBreadcrumbsIntegration.java | 49 +----- .../TempSensorBreadcrumbsIntegration.java | 143 ------------------ .../sentry/android/core/ContextUtilsTest.kt | 2 +- .../PhoneStateBreadcrumbsIntegrationTest.kt | 128 ---------------- .../sentry/android/core/SentryAndroidTest.kt | 4 +- .../TempSensorBreadcrumbsIntegrationTest.kt | 133 ---------------- .../src/main/AndroidManifest.xml | 2 - 12 files changed, 27 insertions(+), 608 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 208e9bf4c4d..e64a52a5d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ - Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) - Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + ## 7.20.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 02725ba5df6..44c34038ddb 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,12 +245,6 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/PhoneStateBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -379,14 +373,7 @@ public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : i public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - -public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : android/hardware/SensorEventListener, io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V - public fun onSensorChanged (Landroid/hardware/SensorEvent;)V + public static fun getDefaultActions ()Ljava/util/List; public fun register (Lio/sentry/IHub;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 d5dfce77b28..32938a39843 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 @@ -301,8 +301,6 @@ static void installDefaultIntegrations( options.addIntegration(new SystemEventsBreadcrumbsIntegration(context)); options.addIntegration( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); - options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); - options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 89fe856631b..c0a018b5b35 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -2,7 +2,6 @@ import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; import static android.content.Context.ACTIVITY_SERVICE; -import static android.content.Context.RECEIVER_EXPORTED; import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED; import android.annotation.SuppressLint; @@ -349,7 +348,7 @@ public static boolean isForegroundImportance() { // If this receiver is listening for broadcasts sent from the system or from other apps, even // other apps that you own—use the RECEIVER_EXPORTED flag. If instead this receiver is // listening only for broadcasts sent by your app, use the RECEIVER_NOT_EXPORTED flag. - return context.registerReceiver(receiver, filter, RECEIVER_EXPORTED); + return context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); } else { return context.registerReceiver(receiver, filter); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java deleted file mode 100644 index 249904fd162..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.sentry.android.core; - -import static android.Manifest.permission.READ_PHONE_STATE; -import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; - -import android.content.Context; -import android.telephony.TelephonyManager; -import io.sentry.Breadcrumb; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.Permissions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class PhoneStateBreadcrumbsIntegration implements Integration, Closeable { - - private final @NotNull Context context; - private @Nullable SentryAndroidOptions options; - @TestOnly @Nullable PhoneStateChangeListener listener; - private @Nullable TelephonyManager telephonyManager; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); - - public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - } - - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs() - && Permissions.hasPermission(context, READ_PHONE_STATE)) { - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startTelephonyListener(hub, options); - } - } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start PhoneStateBreadcrumbsIntegration on executor thread.", - e); - } - } - } - - @SuppressWarnings("deprecation") - private void startTelephonyListener( - final @NotNull IHub hub, final @NotNull SentryOptions options) { - telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - if (telephonyManager != null) { - try { - listener = new PhoneStateChangeListener(hub); - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE); - - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("PhoneStateBreadcrumbs"); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.INFO, e, "TelephonyManager is not available or ready to use."); - } - } else { - options.getLogger().log(SentryLevel.INFO, "TelephonyManager is not available"); - } - } - - @SuppressWarnings("deprecation") - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (telephonyManager != null && listener != null) { - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_NONE); - listener = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration removed."); - } - } - } - - @SuppressWarnings("deprecation") - static final class PhoneStateChangeListener extends android.telephony.PhoneStateListener { - - private final @NotNull IHub hub; - - PhoneStateChangeListener(final @NotNull IHub hub) { - this.hub = hub; - } - - @SuppressWarnings("deprecation") - @Override - public void onCallStateChanged(int state, String incomingNumber) { - // incomingNumber is never used and it's always empty if you don't have permission: - // android.permission.READ_CALL_LOG - if (state == TelephonyManager.CALL_STATE_RINGING) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "CALL_STATE_RINGING"); - breadcrumb.setMessage("Device ringing"); - breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); - } - } - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index ea838975cde..cfa61454bc1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -1,31 +1,17 @@ package io.sentry.android.core; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DELETED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DISABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_ENABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; -import static android.content.Intent.ACTION_APP_ERROR; import static android.content.Intent.ACTION_BATTERY_CHANGED; -import static android.content.Intent.ACTION_BATTERY_LOW; -import static android.content.Intent.ACTION_BATTERY_OKAY; -import static android.content.Intent.ACTION_BOOT_COMPLETED; -import static android.content.Intent.ACTION_BUG_REPORT; import static android.content.Intent.ACTION_CAMERA_BUTTON; import static android.content.Intent.ACTION_CONFIGURATION_CHANGED; import static android.content.Intent.ACTION_DATE_CHANGED; import static android.content.Intent.ACTION_DEVICE_STORAGE_LOW; import static android.content.Intent.ACTION_DEVICE_STORAGE_OK; import static android.content.Intent.ACTION_DOCK_EVENT; +import static android.content.Intent.ACTION_DREAMING_STARTED; +import static android.content.Intent.ACTION_DREAMING_STOPPED; import static android.content.Intent.ACTION_INPUT_METHOD_CHANGED; import static android.content.Intent.ACTION_LOCALE_CHANGED; -import static android.content.Intent.ACTION_MEDIA_BAD_REMOVAL; -import static android.content.Intent.ACTION_MEDIA_MOUNTED; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTABLE; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTED; -import static android.content.Intent.ACTION_POWER_CONNECTED; -import static android.content.Intent.ACTION_POWER_DISCONNECTED; -import static android.content.Intent.ACTION_REBOOT; import static android.content.Intent.ACTION_SCREEN_OFF; import static android.content.Intent.ACTION_SCREEN_ON; import static android.content.Intent.ACTION_SHUTDOWN; @@ -142,52 +128,27 @@ private void startSystemEventsReceiver( } @SuppressWarnings("deprecation") - private static @NotNull List getDefaultActions() { + public static @NotNull List getDefaultActions() { final List actions = new ArrayList<>(); - actions.add(ACTION_APPWIDGET_DELETED); - actions.add(ACTION_APPWIDGET_DISABLED); - actions.add(ACTION_APPWIDGET_ENABLED); - actions.add("android.appwidget.action.APPWIDGET_HOST_RESTORED"); - actions.add("android.appwidget.action.APPWIDGET_RESTORED"); - actions.add(ACTION_APPWIDGET_UPDATE); - actions.add("android.appwidget.action.APPWIDGET_UPDATE_OPTIONS"); - actions.add(ACTION_POWER_CONNECTED); - actions.add(ACTION_POWER_DISCONNECTED); actions.add(ACTION_SHUTDOWN); actions.add(ACTION_AIRPLANE_MODE_CHANGED); - actions.add(ACTION_BATTERY_LOW); - actions.add(ACTION_BATTERY_OKAY); actions.add(ACTION_BATTERY_CHANGED); - actions.add(ACTION_BOOT_COMPLETED); actions.add(ACTION_CAMERA_BUTTON); actions.add(ACTION_CONFIGURATION_CHANGED); - actions.add("android.intent.action.CONTENT_CHANGED"); actions.add(ACTION_DATE_CHANGED); actions.add(ACTION_DEVICE_STORAGE_LOW); actions.add(ACTION_DEVICE_STORAGE_OK); actions.add(ACTION_DOCK_EVENT); - actions.add("android.intent.action.DREAMING_STARTED"); - actions.add("android.intent.action.DREAMING_STOPPED"); + actions.add(ACTION_DREAMING_STARTED); + actions.add(ACTION_DREAMING_STOPPED); actions.add(ACTION_INPUT_METHOD_CHANGED); actions.add(ACTION_LOCALE_CHANGED); - actions.add(ACTION_REBOOT); actions.add(ACTION_SCREEN_OFF); actions.add(ACTION_SCREEN_ON); actions.add(ACTION_TIMEZONE_CHANGED); actions.add(ACTION_TIME_CHANGED); actions.add("android.os.action.DEVICE_IDLE_MODE_CHANGED"); actions.add("android.os.action.POWER_SAVE_MODE_CHANGED"); - // The user pressed the "Report" button in the crash/ANR dialog. - actions.add(ACTION_APP_ERROR); - // Show activity for reporting a bug. - actions.add(ACTION_BUG_REPORT); - - // consider if somebody mounted or ejected a sdcard - actions.add(ACTION_MEDIA_BAD_REMOVAL); - actions.add(ACTION_MEDIA_MOUNTED); - actions.add(ACTION_MEDIA_UNMOUNTABLE); - actions.add(ACTION_MEDIA_UNMOUNTED); - return actions; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index b94a06b9768..8b137891791 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -1,144 +1 @@ -package io.sentry.android.core; -import static android.content.Context.SENSOR_SERVICE; -import static io.sentry.TypeCheckHint.ANDROID_SENSOR_EVENT; -import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; - -import android.content.Context; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import io.sentry.Breadcrumb; -import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class TempSensorBreadcrumbsIntegration - implements Integration, Closeable, SensorEventListener { - - private final @NotNull Context context; - private @Nullable IHub hub; - private @Nullable SentryAndroidOptions options; - - @TestOnly @Nullable SensorManager sensorManager; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); - - public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - } - - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventsBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs()) { - - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startSensorListener(options); - } - } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start TempSensorBreadcrumbsIntegration on executor thread.", - e); - } - } - } - - private void startSensorListener(final @NotNull SentryOptions options) { - try { - sensorManager = (SensorManager) context.getSystemService(SENSOR_SERVICE); - if (sensorManager != null) { - final Sensor defaultSensor = - sensorManager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE); - if (defaultSensor != null) { - sensorManager.registerListener(this, defaultSensor, SensorManager.SENSOR_DELAY_NORMAL); - - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("TempSensorBreadcrumbs"); - } else { - options.getLogger().log(SentryLevel.INFO, "TYPE_AMBIENT_TEMPERATURE is not available."); - } - } else { - options.getLogger().log(SentryLevel.INFO, "SENSOR_SERVICE is not available."); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, e, "Failed to init. the SENSOR_SERVICE."); - } - } - - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (sensorManager != null) { - sensorManager.unregisterListener(this); - sensorManager = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration removed."); - } - } - } - - @Override - public void onSensorChanged(final @NotNull SensorEvent event) { - final float[] values = event.values; - // return if data is not available or zero'ed - if (values == null || values.length == 0 || values[0] == 0f) { - return; - } - - if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "TYPE_AMBIENT_TEMPERATURE"); - breadcrumb.setData("accuracy", event.accuracy); - breadcrumb.setData("timestamp", event.timestamp); - breadcrumb.setLevel(SentryLevel.INFO); - breadcrumb.setData("degree", event.values[0]); // Celsius - - final Hint hint = new Hint(); - hint.set(ANDROID_SENSOR_EVENT, event); - - hub.addBreadcrumb(breadcrumb, hint); - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) {} -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index 588a32a6569..756be16a3db 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -193,7 +193,7 @@ class ContextUtilsTest { val context = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.TIRAMISU) ContextUtils.registerReceiver(context, buildInfo, receiver, filter) - verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_EXPORTED)) + verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_NOT_EXPORTED)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt deleted file mode 100644 index 2b6ca801dae..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.sentry.android.core - -import android.content.Context -import android.telephony.PhoneStateListener -import android.telephony.TelephonyManager -import io.sentry.Breadcrumb -import io.sentry.IHub -import io.sentry.ISentryExecutorService -import io.sentry.SentryLevel -import io.sentry.test.DeferredExecutorService -import io.sentry.test.ImmediateExecutorService -import org.mockito.kotlin.any -import org.mockito.kotlin.check -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class PhoneStateBreadcrumbsIntegrationTest { - - private class Fixture { - val context = mock() - val manager = mock() - val options = SentryAndroidOptions() - - fun getSut(executorService: ISentryExecutorService = ImmediateExecutorService()): PhoneStateBreadcrumbsIntegration { - options.executorService = executorService - whenever(context.getSystemService(eq(Context.TELEPHONY_SERVICE))).thenReturn(manager) - - return PhoneStateBreadcrumbsIntegration(context) - } - } - - private val fixture = Fixture() - - @Test - fun `When system events breadcrumb is enabled, it registers callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_CALL_STATE)) - assertNotNull(sut.listener) - } - - @Test - fun `Phone state callback is registered in the executorService`() { - val sut = fixture.getSut(mock()) - val hub = mock() - sut.register(hub, fixture.options) - - assertNull(sut.listener) - } - - @Test - fun `When system events breadcrumb is disabled, it doesn't register callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register( - hub, - fixture.options.apply { - isEnableSystemEventBreadcrumbs = false - } - ) - verify(fixture.manager, never()).listen(any(), any()) - assertNull(sut.listener) - } - - @Test - fun `When ActivityBreadcrumbsIntegration is closed, it should unregister the callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.close() - verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_NONE)) - assertNull(sut.listener) - } - - @Test - fun `when hub is closed right after start, integration is not registered`() { - val deferredExecutorService = DeferredExecutorService() - val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(mock(), fixture.options) - assertNull(sut.listener) - sut.close() - deferredExecutorService.runAll() - assertNull(sut.listener) - } - - @Test - fun `When on call state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_RINGING, null) - - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When on idle state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_IDLE, null) - verify(hub, never()).addBreadcrumb(any()) - } - - @Test - fun `When on offhook state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK, null) - verify(hub, never()).addBreadcrumb(any()) - } -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index ec2b3db4ce3..a4608582f78 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -461,7 +461,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(21, options.integrations.size) + assertEquals(19, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -479,8 +479,6 @@ class SentryAndroidTest { it is AppComponentsBreadcrumbsIntegration || it is SystemEventsBreadcrumbsIntegration || it is NetworkBreadcrumbsIntegration || - it is TempSensorBreadcrumbsIntegration || - it is PhoneStateBreadcrumbsIntegration || it is SpotlightIntegration || it is ReplayIntegration } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt deleted file mode 100644 index d443b1e3458..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package io.sentry.android.core - -import android.content.Context -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import io.sentry.Breadcrumb -import io.sentry.Hint -import io.sentry.IHub -import io.sentry.ISentryExecutorService -import io.sentry.SentryLevel -import io.sentry.TypeCheckHint -import io.sentry.test.DeferredExecutorService -import io.sentry.test.ImmediateExecutorService -import io.sentry.test.getDeclaredCtor -import io.sentry.test.injectForField -import org.mockito.kotlin.any -import org.mockito.kotlin.check -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class TempSensorBreadcrumbsIntegrationTest { - private class Fixture { - val context = mock() - val manager = mock() - val sensor = mock() - val options = SentryAndroidOptions() - - fun getSut(executorService: ISentryExecutorService = ImmediateExecutorService()): TempSensorBreadcrumbsIntegration { - options.executorService = executorService - whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(manager) - whenever(manager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE)).thenReturn(sensor) - return TempSensorBreadcrumbsIntegration(context) - } - } - - private val fixture = Fixture() - - @Test - fun `When system events breadcrumb is enabled, it registers callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - verify(fixture.manager).registerListener(any(), any(), eq(SensorManager.SENSOR_DELAY_NORMAL)) - assertNotNull(sut.sensorManager) - } - - @Test - fun `temp sensor listener is registered in the executorService`() { - val sut = fixture.getSut(executorService = mock()) - val hub = mock() - sut.register(hub, fixture.options) - - assertNull(sut.sensorManager) - } - - @Test - fun `When system events breadcrumb is disabled, it should not register a callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register( - hub, - fixture.options.apply { - isEnableSystemEventBreadcrumbs = false - } - ) - verify(fixture.manager, never()).registerListener(any(), any(), any()) - assertNull(sut.sensorManager) - } - - @Test - fun `When TempSensorBreadcrumbsIntegration is closed, it should unregister the callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.close() - verify(fixture.manager).unregisterListener(any()) - assertNull(sut.sensorManager) - } - - @Test - fun `when hub is closed right after start, integration is not registered`() { - val deferredExecutorService = DeferredExecutorService() - val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(mock(), fixture.options) - assertNull(sut.sensorManager) - sut.close() - deferredExecutorService.runAll() - assertNull(sut.sensorManager) - } - - @Test - fun `When onSensorChanged received, add a breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - val sensorCtor = "android.hardware.SensorEvent".getDeclaredCtor(emptyArray()) - val sensorEvent: SensorEvent = sensorCtor.newInstance() as SensorEvent - sensorEvent.injectForField("values", FloatArray(2) { 1F }) - sut.onSensorChanged(sensorEvent) - - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.INFO, it.level) - }, - check { - assertEquals(sensorEvent, it.get(TypeCheckHint.ANDROID_SENSOR_EVENT)) - } - ) - } - - @Test - fun `When onSensorChanged received and null values, do not add a breadcrumb`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - val event = mock() - assertNull(event.values) - sut.onSensorChanged(event) - - verify(hub, never()).addBreadcrumb(any()) - } -} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index d8ae6c709d9..5860c3863eb 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -6,8 +6,6 @@ - - From 0b511c952156b6180449bb9582652dedb91b0a22 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jan 2025 14:07:10 +0100 Subject: [PATCH 03/42] Only provide {{auto}} ip-address if sendDefaultPii is enabled --- .../android/core/AnrV2EventProcessor.java | 2 +- .../core/DefaultAndroidEventProcessor.java | 2 +- .../android/core/AnrV2EventProcessorTest.kt | 18 +++++++++++++++--- .../core/DefaultAndroidEventProcessorTest.kt | 19 ++++++++++++++++--- .../java/io/sentry/MainEventProcessor.java | 2 +- .../java/io/sentry/MainEventProcessorTest.kt | 10 ++++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index e914029c30c..b35b53f150f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -575,7 +575,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index a2833d2b346..f8c91ec994c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -156,7 +156,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 80ae9467114..21e18a594bb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -85,7 +85,6 @@ class AnrV2EventProcessorTest { lateinit var context: Context val options = SentryAndroidOptions().apply { setLogger(NoOpLogger.getInstance()) - isSendDefaultPii = true } fun getSut( @@ -93,10 +92,13 @@ class AnrV2EventProcessorTest { currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, - replayErrorSampleRate: Double? = null + replayErrorSampleRate: Double? = null, + isSendDefaultPii: Boolean = true ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" + options.isSendDefaultPii = isSendDefaultPii + whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -278,6 +280,7 @@ class AnrV2EventProcessorTest { // user assertEquals("bot", processed.user!!.username) assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) // trace assertEquals("ui.load", processed.contexts.trace!!.operation) // tags @@ -304,6 +307,13 @@ class AnrV2EventProcessorTest { assertEquals("Google Chrome", processed.contexts.browser!!.name) } + @Test + fun `when backfillable event is enrichable, does not backfill user ip`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + assertNull(processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -617,6 +627,7 @@ class AnrV2EventProcessorTest { hint: Hint, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, + isSendDefaultPii: Boolean = true, configureEvent: SentryEvent.() -> Unit = {} ): SentryEvent { val original = SentryEvent().apply(configureEvent) @@ -624,7 +635,8 @@ class AnrV2EventProcessorTest { val processor = fixture.getSut( tmpDir, populateScopeCache = populateScopeCache, - populateOptionsCache = populateOptionsCache + populateOptionsCache = populateOptionsCache, + isSendDefaultPii = isSendDefaultPii ) return processor.process(original, hint)!! } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 80954f67a5f..0dfcf663bff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -66,7 +66,8 @@ class DefaultAndroidEventProcessorTest { lateinit var sentryTracer: SentryTracer - fun getSut(context: Context): DefaultAndroidEventProcessor { + fun getSut(context: Context, isSendDefaultPii: Boolean = false): DefaultAndroidEventProcessor { + options.isSendDefaultPii = isSendDefaultPii whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("", ""), hub) return DefaultAndroidEventProcessor(context, buildInfo, options) @@ -284,8 +285,20 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `when event user data does not have ip address set, sets {{auto}} as the ip address`() { - val sut = fixture.getSut(context) + fun `when event user data does not have ip address set, sets no ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { + user = User() + } + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + + @Test + fun `when event user data does not have ip address set, sets {{auto}} if sendDefaultPii is true`() { + val sut = fixture.getSut(context, isSendDefaultPii = true) val event = SentryEvent().apply { user = User() } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 45be9212bac..d14264da570 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -245,7 +245,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { user = new User(); event.setUser(user); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 8881b6d386a..7933906821c 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -306,6 +306,16 @@ class MainEventProcessorTest { } } + @Test + fun `when event does not have ip address set, do not enrich ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(sendDefaultPii = true) From ef02e3a23927d68319600dd98930bbe65d0c2ee8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jan 2025 14:14:48 +0100 Subject: [PATCH 04/42] Update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3857217a987..b1e4b2a93c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Behavioural Changes + +- The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) + - This change gives you control over IP address collection directly on the client + ## 7.20.0 ### Features From 63819218108dbc29fa7a4f4f2deed9ab5923f213 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 20 Jan 2025 14:48:36 +0100 Subject: [PATCH 05/42] Reduce the number of IPC calls (#4058) * Remove binder call for external storage * Remove binder call for memory in profiler * Cache static values to avoid binder calls * Comment * Changelog * Formatting * Fix tests * Minor fixes * change protected method in final class to private --------- Co-authored-by: Markus Hintersteiner Co-authored-by: stefanosiano --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 12 ++ .../core/AndroidOptionsInitializer.java | 4 +- .../core/AndroidTransactionProfiler.java | 32 +--- .../android/core/AnrV2EventProcessor.java | 26 +-- .../io/sentry/android/core/ContextUtils.java | 174 +++++++++++++----- .../core/DefaultAndroidEventProcessor.java | 2 +- .../sentry/android/core/DeviceInfoUtil.java | 20 +- .../android/core/InternalSentrySdk.java | 2 +- .../android/core/ManifestMetadataReader.java | 11 +- .../core/util/AndroidLazyEvaluator.java | 68 +++++++ .../core/ActivityLifecycleIntegrationTest.kt | 1 + .../core/AndroidOptionsInitializerTest.kt | 1 + .../android/core/AnrV2EventProcessorTest.kt | 1 + .../sentry/android/core/ContextUtilsTest.kt | 13 +- .../core/ManifestMetadataReaderTest.kt | 6 + .../android/core/SentryInitProviderTest.kt | 1 + .../android/core/SentryLogcatAdapterTest.kt | 1 + 18 files changed, 254 insertions(+), 122 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e64a52a5d10..c354ae38a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) - Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) - Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) ### Behavioural Changes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 44c34038ddb..99ca27dc733 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -193,6 +193,7 @@ public final class io/sentry/android/core/DeviceInfoUtil { public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo; + public fun getTotalMemory ()Ljava/lang/Long; public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; public static fun resetInstance ()V } @@ -501,3 +502,14 @@ public class io/sentry/android/core/performance/WindowContentChangedCallback : i public fun onContentChanged ()V } +public final class io/sentry/android/core/util/AndroidLazyEvaluator { + public fun (Lio/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator;)V + public fun getValue (Landroid/content/Context;)Ljava/lang/Object; + public fun resetValue ()V + public fun setValue (Ljava/lang/Object;)V +} + +public abstract interface class io/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator { + public abstract fun evaluate (Landroid/content/Context;)Ljava/lang/Object; +} + 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 32938a39843..80d81671ee5 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 @@ -320,8 +320,8 @@ private static void readDefaultOptionValues( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final @Nullable PackageInfo packageInfo = + ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { // Sets App's release if not set by Manifest if (options.getRelease() == null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index 41e57a886a4..f4aa3cf0987 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -1,10 +1,8 @@ package io.sentry.android.core; -import static android.content.Context.ACTIVITY_SERVICE; import static java.util.concurrent.TimeUnit.SECONDS; import android.annotation.SuppressLint; -import android.app.ActivityManager; import android.content.Context; import android.os.Build; import android.os.Process; @@ -265,9 +263,12 @@ public synchronized void bindTransaction(final @NotNull ITransaction transaction transactionsCounter = 0; String totalMem = "0"; - ActivityManager.MemoryInfo memInfo = getMemInfo(); - if (memInfo != null) { - totalMem = Long.toString(memInfo.totalMem); + final @Nullable Long memory = + (options instanceof SentryAndroidOptions) + ? DeviceInfoUtil.getInstance(context, (SentryAndroidOptions) options).getTotalMemory() + : null; + if (memory != null) { + totalMem = Long.toString(memory); } String[] abis = Build.SUPPORTED_ABIS; @@ -333,27 +334,6 @@ public void close() { } } - /** - * Get MemoryInfo object representing the memory state of the application. - * - * @return MemoryInfo object representing the memory state of the application - */ - private @Nullable ActivityManager.MemoryInfo getMemInfo() { - try { - ActivityManager actManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); - ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); - if (actManager != null) { - actManager.getMemoryInfo(memInfo); - return memInfo; - } - logger.log(SentryLevel.INFO, "Error getting MemoryInfo."); - return null; - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting MemoryInfo.", e); - return null; - } - } - @TestOnly int getTransactionsCounter() { return transactionsCounter; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index e914029c30c..36f855fea52 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -374,14 +374,13 @@ private void setApp(final @NotNull SentryBaseEvent event, final @NotNull Object if (app == null) { app = new App(); } - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); // TODO: not entirely correct, because we define background ANRs as not the ones of // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR happened // but it's our best effort for now. We could serialize AppState in theory. app.setInForeground(!isBackgroundAnr(hint)); - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { app.setAppIdentifier(packageInfo.packageName); } @@ -592,8 +591,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { try { final ContextUtils.SideLoadedInfo sideLoadedInfo = - ContextUtils.retrieveSideLoadedInfo(context, options.getLogger(), buildInfoProvider); - + DeviceInfoUtil.getInstance(context, options).getSideLoadedInfo(); if (sideLoadedInfo != null) { final @NotNull Map tags = sideLoadedInfo.asTags(); for (Map.Entry entry : tags.entrySet()) { @@ -662,7 +660,8 @@ private void setDevice(final @NotNull SentryBaseEvent event) { private void mergeOS(final @NotNull SentryBaseEvent event) { final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - final OperatingSystem androidOS = getOperatingSystem(); + final OperatingSystem androidOS = + DeviceInfoUtil.getInstance(context, options).getOperatingSystem(); // make Android OS the main OS using the 'os' key event.getContexts().setOperatingSystem(androidOS); @@ -678,20 +677,5 @@ private void mergeOS(final @NotNull SentryBaseEvent event) { event.getContexts().put(osNameKey, currentOS); } } - - private @NotNull OperatingSystem getOperatingSystem() { - OperatingSystem os = new OperatingSystem(); - os.setName("Android"); - os.setVersion(Build.VERSION.RELEASE); - os.setBuild(Build.DISPLAY); - - try { - os.setKernelVersion(ContextUtils.getKernelVersion(options.getLogger())); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting OperatingSystem.", e); - } - - return os; - } // endregion } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index c0a018b5b35..086c2a101c7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -19,7 +19,9 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.util.AndroidLazyEvaluator; import io.sentry.protocol.App; +import io.sentry.util.LazyEvaluator; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -29,6 +31,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class ContextUtils { @@ -62,6 +65,106 @@ public boolean isSideLoaded() { private ContextUtils() {} + // to avoid doing a bunch of Binder calls we use LazyEvaluator to cache the values that are static + // during the app process running + + private static final @NotNull AndroidLazyEvaluator deviceName = + new AndroidLazyEvaluator<>( + (context) -> Settings.Global.getString(context.getContentResolver(), "device_name")); + + private static final @NotNull LazyEvaluator isForegroundImportance = + new LazyEvaluator<>( + () -> { + try { + final ActivityManager.RunningAppProcessInfo appProcessInfo = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(appProcessInfo); + return appProcessInfo.importance == IMPORTANCE_FOREGROUND; + } catch (Throwable ignored) { + // should never happen + } + return false; + }); + + /** + * Since this packageInfo uses flags 0 we can assume it's static and cache it as the package name + * or version code cannot change during runtime, only after app update (which will spin up a new + * process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticPackageInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.PackageInfoFlags.of(0)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticPackageInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator applicationName = + new AndroidLazyEvaluator<>( + context -> { + try { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + final int stringId = applicationInfo.labelRes; + if (stringId == 0) { + if (applicationInfo.nonLocalizedLabel != null) { + return applicationInfo.nonLocalizedLabel.toString(); + } + return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); + } else { + return context.getString(stringId); + } + } catch (Throwable e) { + return null; + } + }); + + /** + * Since this applicationInfo uses the same flag (METADATA) we can assume it's static and cache it + * as the manifest metadata cannot change during runtime, only after app update (which will spin + * up a new process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticAppInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo( + context.getPackageName(), + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticAppInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + } catch (Throwable e) { + return null; + } + }); + /** * Return the Application's PackageInfo if possible, or null. * @@ -69,10 +172,12 @@ private ContextUtils() {} */ @Nullable static PackageInfo getPackageInfo( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider) { - return getPackageInfo(context, 0, logger, buildInfoProvider); + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { + return staticPackageInfo33.getValue(context); + } else { + return staticPackageInfo.getValue(context); + } } /** @@ -109,22 +214,14 @@ static PackageInfo getPackageInfo( * @return the Application's ApplicationInfo if possible, or throws */ @SuppressLint("NewApi") - @NotNull + @Nullable @SuppressWarnings("deprecation") static ApplicationInfo getApplicationInfo( - final @NotNull Context context, - final long flag, - final @NotNull BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { - return context - .getPackageManager() - .getApplicationInfo( - context.getPackageName(), PackageManager.ApplicationInfoFlags.of(flag)); + return staticAppInfo33.getValue(context); } else { - return context - .getPackageManager() - .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + return staticAppInfo.getValue(context); } } @@ -168,15 +265,7 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { */ @ApiStatus.Internal public static boolean isForegroundImportance() { - try { - final ActivityManager.RunningAppProcessInfo appProcessInfo = - new ActivityManager.RunningAppProcessInfo(); - ActivityManager.getMyMemoryState(appProcessInfo); - return appProcessInfo.importance == IMPORTANCE_FOREGROUND; - } catch (Throwable ignored) { - // should never happen - } - return false; + return isForegroundImportance.getValue(); } /** @@ -212,7 +301,7 @@ public static boolean isForegroundImportance() { final @NotNull BuildInfoProvider buildInfoProvider) { String packageName = null; try { - final PackageInfo packageInfo = getPackageInfo(context, logger, buildInfoProvider); + final PackageInfo packageInfo = getPackageInfo(context, buildInfoProvider); final PackageManager packageManager = context.getPackageManager(); if (packageInfo != null && packageManager != null) { @@ -238,24 +327,8 @@ public static boolean isForegroundImportance() { * * @return Application name */ - static @Nullable String getApplicationName( - final @NotNull Context context, final @NotNull ILogger logger) { - try { - final ApplicationInfo applicationInfo = context.getApplicationInfo(); - final int stringId = applicationInfo.labelRes; - if (stringId == 0) { - if (applicationInfo.nonLocalizedLabel != null) { - return applicationInfo.nonLocalizedLabel.toString(); - } - return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); - } else { - return context.getString(stringId); - } - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting application name.", e); - } - - return null; + static @Nullable String getApplicationName(final @NotNull Context context) { + return applicationName.getValue(context); } /** @@ -289,7 +362,7 @@ public static boolean isForegroundImportance() { } static @Nullable String getDeviceName(final @NotNull Context context) { - return Settings.Global.getString(context.getContentResolver(), "device_name"); + return deviceName.getValue(context); } @SuppressWarnings("deprecation") @@ -397,4 +470,15 @@ public static Context getApplicationContext(final @NotNull Context context) { } return context; } + + @TestOnly + static void resetInstance() { + deviceName.resetValue(); + isForegroundImportance.resetValue(); + staticPackageInfo33.resetValue(); + staticPackageInfo.resetValue(); + applicationName.resetValue(); + staticAppInfo33.resetValue(); + staticAppInfo.resetValue(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index a2833d2b346..8cc00ed2188 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -250,7 +250,7 @@ private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String } private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); final @NotNull TimeSpan appStartTimeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); if (appStartTimeSpan.hasStarted()) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index e2dfee2705a..aba2d1ed511 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -9,7 +9,6 @@ import android.content.IntentFilter; import android.os.BatteryManager; import android.os.Build; -import android.os.Environment; import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; @@ -156,8 +155,13 @@ public OperatingSystem getOperatingSystem() { return os; } + @Nullable + public Long getTotalMemory() { + return totalMem; + } + @NotNull - protected OperatingSystem retrieveOperatingSystemInformation() { + private OperatingSystem retrieveOperatingSystemInformation() { final OperatingSystem os = new OperatingSystem(); os.setName("Android"); @@ -384,15 +388,14 @@ private Long getUnusedInternalStorage(final @NotNull StatFs stat) { @Nullable private StatFs getExternalStorageStat(final @Nullable File internalStorage) { - if (!isExternalStorageMounted()) { + try { File path = getExternalStorageDep(internalStorage); if (path != null) { // && path.canRead()) { canRead() will read return false return new StatFs(path.getPath()); } + } catch (Throwable e) { options.getLogger().log(SentryLevel.INFO, "Not possible to read external files directory"); - return null; } - options.getLogger().log(SentryLevel.INFO, "External storage is not mounted or emulated."); return null; } @@ -444,13 +447,6 @@ private Long getTotalExternalStorage(final @NotNull StatFs stat) { } } - private boolean isExternalStorageMounted() { - final String storageState = Environment.getExternalStorageState(); - return (Environment.MEDIA_MOUNTED.equals(storageState) - || Environment.MEDIA_MOUNTED_READ_ONLY.equals(storageState)) - && !Environment.isExternalStorageEmulated(); - } - /** * Get the unused amount of external storage, in bytes, or null if no external storage is mounted. * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index a3a15d7326c..2125be1671a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -108,7 +108,7 @@ public static Map serializeScope( if (app == null) { app = new App(); } - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); final @NotNull TimeSpan appStartTimeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 2d2df5700af..6e8a64530fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -2,7 +2,6 @@ import android.content.Context; import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; import android.os.Bundle; import io.sentry.ILogger; import io.sentry.SentryIntegrationPackageStorage; @@ -538,18 +537,14 @@ static boolean isAutoInit(final @NotNull Context context, final @NotNull ILogger * * @param context the application context * @return the Bundle attached to the PackageManager - * @throws PackageManager.NameNotFoundException if the package name is non-existent */ private static @Nullable Bundle getMetadata( final @NotNull Context context, final @NotNull ILogger logger, - final @Nullable BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @Nullable BuildInfoProvider buildInfoProvider) { final ApplicationInfo app = ContextUtils.getApplicationInfo( - context, - PackageManager.GET_META_DATA, - buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); - return app.metaData; + context, buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); + return app != null ? app.metaData : null; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java new file mode 100644 index 00000000000..beb9ff8e8ed --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java @@ -0,0 +1,68 @@ +package io.sentry.android.core.util; + +import android.content.Context; +import io.sentry.util.LazyEvaluator; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. Same as {@link LazyEvaluator} but accepts Context as an + * argument for {@link AndroidLazyEvaluator#getValue}. + */ +@ApiStatus.Internal +public final class AndroidLazyEvaluator { + + private volatile @Nullable T value = null; + private final @NotNull AndroidEvaluator evaluator; + + /** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. + * + * @param evaluator The function to evaluate. + */ + public AndroidLazyEvaluator(final @NotNull AndroidEvaluator evaluator) { + this.evaluator = evaluator; + } + + /** + * Executes the evaluator function and caches its result, so that it's called only once, unless + * resetValue is called. + * + * @return The result of the evaluator function. + */ + public @Nullable T getValue(final @NotNull Context context) { + if (value == null) { + synchronized (this) { + if (value == null) { + value = evaluator.evaluate(context); + } + } + } + + return value; + } + + public void setValue(final @Nullable T value) { + synchronized (this) { + this.value = value; + } + } + + /** + * Resets the internal value and forces the evaluator function to be called the next time + * getValue() is called. + */ + public void resetValue() { + synchronized (this) { + this.value = null; + } + } + + public interface AndroidEvaluator { + @Nullable + T evaluate(@NotNull Context context); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index be000b7517c..b212ed2feab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -132,6 +132,7 @@ class ActivityLifecycleIntegrationTest { @BeforeTest fun `reset instance`() { AppStartMetrics.getInstance().clear() + ContextUtils.resetInstance() context = ApplicationProvider.getApplicationContext() val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index ed2fa3338a5..f234f7a6402 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -164,6 +164,7 @@ class AndroidOptionsInitializerTest { @BeforeTest fun `set up`() { + ContextUtils.resetInstance() val appContext = ApplicationProvider.getApplicationContext() fixture = Fixture(appContext, appContext.cacheDir) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 80ae9467114..f04830182d5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -170,6 +170,7 @@ class AnrV2EventProcessorTest { @BeforeTest fun `set up`() { + DeviceInfoUtil.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index 756be16a3db..dc224a5bee6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -42,6 +42,7 @@ class ContextUtilsTest { @BeforeTest fun `set up`() { + ContextUtils.resetInstance() context = ApplicationProvider.getApplicationContext() logger = NoOpLogger.getInstance() ShadowBuild.reset() @@ -51,20 +52,20 @@ class ContextUtilsTest { @Test fun `Given a valid context, returns a valid PackageInfo`() { - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(context, mock()) assertNotNull(packageInfo) } @Test fun `Given an invalid context, do not throw Error`() { // as Context is not fully mocked, it'll throw NPE but catch it and return null - val packageInfo = ContextUtils.getPackageInfo(mock(), mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(mock(), mock()) assertNull(packageInfo) } @Test fun `Given a valid PackageInfo, returns a valid versionCode`() { - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(context, mock()) val versionCode = ContextUtils.getVersionCode(packageInfo!!, mock()) assertNotNull(versionCode) @@ -73,7 +74,7 @@ class ContextUtilsTest { @Test fun `Given a valid PackageInfo, returns a valid versionName`() { // VersionName is null during tests, so we mock it the second time - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock())!! + val packageInfo = ContextUtils.getPackageInfo(context, mock())!! val versionName = ContextUtils.getVersionName(packageInfo) assertNull(versionName) val mockedPackageInfo = spy(packageInfo) { it.versionName = "" } @@ -83,13 +84,13 @@ class ContextUtilsTest { @Test fun `when context is valid, getApplicationName returns application name`() { - val appName = ContextUtils.getApplicationName(context, logger) + val appName = ContextUtils.getApplicationName(context) assertEquals("io.sentry.android.core.test", appName) } @Test fun `when context is invalid, getApplicationName returns null`() { - val appName = ContextUtils.getApplicationName(mock(), logger) + val appName = ContextUtils.getApplicationName(mock()) assertNull(appName) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d60f47bd2cc..e1e5b3b9aa6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -37,6 +38,11 @@ class ManifestMetadataReaderTest { private val fixture = Fixture() + @BeforeTest + fun `set up`() { + ContextUtils.resetInstance() + } + @Test fun `isAutoInit won't throw exception and is enabled by default`() { fixture.options.setDebug(true) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index 5b546523d01..927e8792376 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -20,6 +20,7 @@ class SentryInitProviderTest { @BeforeTest fun `set up`() { Sentry.close() + ContextUtils.resetInstance() } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1939e7ed801..f6d6d229852 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -42,6 +42,7 @@ class SentryLogcatAdapterTest { fun `set up`() { Sentry.close() AppStartMetrics.getInstance().clear() + ContextUtils.resetInstance() breadcrumbs.clear() fixture.initSut { From 0126da6565926904aa2a5589bba740de0ad6bb9b Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 20 Jan 2025 14:39:13 +0000 Subject: [PATCH 06/42] release: 7.20.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e4b2a93c5..7e427f6efe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.20.1 ### Behavioural Changes diff --git a/gradle.properties b/gradle.properties index 65fe48ea942..2f9c5d420bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.20.0 +versionName=7.20.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 3cdb90527de31c2d72b377ed0e34fefe80b80bc3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 20 Jan 2025 16:47:09 +0100 Subject: [PATCH 07/42] Only send {{auto}} ip-adress if sendDefaultPii is enabled (7.x.x) (#4071) * Only provide {{auto}} ip-address if sendDefaultPii is enabled * Update changelog --- CHANGELOG.md | 3 +++ .../android/core/AnrV2EventProcessor.java | 2 +- .../core/DefaultAndroidEventProcessor.java | 2 +- .../android/core/AnrV2EventProcessorTest.kt | 18 +++++++++++++++--- .../core/DefaultAndroidEventProcessorTest.kt | 19 ++++++++++++++++--- .../java/io/sentry/MainEventProcessor.java | 2 +- .../java/io/sentry/MainEventProcessorTest.kt | 10 ++++++++++ 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c354ae38a95..9b93818aa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ SentryAndroid.init(context) { options -> If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). +- The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) + - This change gives you control over IP address collection directly on the client + ## 7.20.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 36f855fea52..990facd6244 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -574,7 +574,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 8cc00ed2188..8edb9737e25 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -156,7 +156,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index f04830182d5..6065e81e086 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -85,7 +85,6 @@ class AnrV2EventProcessorTest { lateinit var context: Context val options = SentryAndroidOptions().apply { setLogger(NoOpLogger.getInstance()) - isSendDefaultPii = true } fun getSut( @@ -93,10 +92,13 @@ class AnrV2EventProcessorTest { currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, - replayErrorSampleRate: Double? = null + replayErrorSampleRate: Double? = null, + isSendDefaultPii: Boolean = true ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" + options.isSendDefaultPii = isSendDefaultPii + whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -279,6 +281,7 @@ class AnrV2EventProcessorTest { // user assertEquals("bot", processed.user!!.username) assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) // trace assertEquals("ui.load", processed.contexts.trace!!.operation) // tags @@ -305,6 +308,13 @@ class AnrV2EventProcessorTest { assertEquals("Google Chrome", processed.contexts.browser!!.name) } + @Test + fun `when backfillable event is enrichable, does not backfill user ip`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + assertNull(processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -618,6 +628,7 @@ class AnrV2EventProcessorTest { hint: Hint, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, + isSendDefaultPii: Boolean = true, configureEvent: SentryEvent.() -> Unit = {} ): SentryEvent { val original = SentryEvent().apply(configureEvent) @@ -625,7 +636,8 @@ class AnrV2EventProcessorTest { val processor = fixture.getSut( tmpDir, populateScopeCache = populateScopeCache, - populateOptionsCache = populateOptionsCache + populateOptionsCache = populateOptionsCache, + isSendDefaultPii = isSendDefaultPii ) return processor.process(original, hint)!! } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 80954f67a5f..0dfcf663bff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -66,7 +66,8 @@ class DefaultAndroidEventProcessorTest { lateinit var sentryTracer: SentryTracer - fun getSut(context: Context): DefaultAndroidEventProcessor { + fun getSut(context: Context, isSendDefaultPii: Boolean = false): DefaultAndroidEventProcessor { + options.isSendDefaultPii = isSendDefaultPii whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("", ""), hub) return DefaultAndroidEventProcessor(context, buildInfo, options) @@ -284,8 +285,20 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `when event user data does not have ip address set, sets {{auto}} as the ip address`() { - val sut = fixture.getSut(context) + fun `when event user data does not have ip address set, sets no ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { + user = User() + } + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + + @Test + fun `when event user data does not have ip address set, sets {{auto}} if sendDefaultPii is true`() { + val sut = fixture.getSut(context, isSendDefaultPii = true) val event = SentryEvent().apply { user = User() } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 45be9212bac..d14264da570 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -245,7 +245,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { user = new User(); event.setUser(user); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 8881b6d386a..7933906821c 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -306,6 +306,16 @@ class MainEventProcessorTest { } } + @Test + fun `when event does not have ip address set, do not enrich ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(sendDefaultPii = true) From b1c5c1b81e764d6815b57d66f50bf2842c62c103 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 20 Jan 2025 15:53:21 +0000 Subject: [PATCH 08/42] release: 7.21.0-beta.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f93c2422c3..171ba0baba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.21.0-beta.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 2f9c5d420bf..4b18e3040bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.20.1 +versionName=7.21.0-beta.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 5a70546c2fae7dd8c373c65f8dfaf88b8f88489d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 27 Jan 2025 20:43:55 +0100 Subject: [PATCH 09/42] Prep changelog for 7.21.0 release --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 171ba0baba1..adbf2580573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + ## 7.21.0-beta.1 ### Fixes From 56c8730c65915b528a1daa2fa1ec5203d12560e6 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 27 Jan 2025 19:45:45 +0000 Subject: [PATCH 10/42] release: 7.21.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adbf2580573..99fd12bed90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.21.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index 4b18e3040bf..e423d73f7f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.21.0-beta.1 +versionName=7.21.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 47a39032827023dd2baa10cd76f1325a9ac2bbae Mon Sep 17 00:00:00 2001 From: Richard Z Date: Thu, 6 Feb 2025 18:00:48 -0500 Subject: [PATCH 11/42] Modifier.sentryTag uses Modifier.Node (#4029) * Modifier.sentryTag uses Modifier.Node * Update Changelog * Add UI test for SentryModifier * Make sentrymodifier a robolectric test * Remove redundant dep --------- Co-authored-by: Markus Hintersteiner Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 7 +++ buildSrc/src/main/java/Config.kt | 1 + sentry-compose/build.gradle.kts | 5 ++ .../io/sentry/compose/SentryModifier.kt | 41 +++++++++++-- .../compose/SentryModifierComposeTest.kt | 59 +++++++++++++++++++ 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fd12bed90..dc74fdae405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) + - This allows Composables that use this modifier to be skippable + ## 7.21.0 ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index bf5dba03be5..8f7f495e27f 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -201,6 +201,7 @@ object Config { val javaFaker = "com.github.javafaker:javafaker:1.0.2" val msgpack = "org.msgpack:msgpack-core:0.9.8" val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" + val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$composeVersion" } object QualityPlugins { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 114c08a22ff..a31027a5a3a 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -60,6 +60,11 @@ kotlin { implementation(Config.TestLibs.mockitoKotlin) implementation(Config.TestLibs.mockitoInline) implementation(Config.Libs.composeNavigation) + implementation(Config.TestLibs.robolectric) + implementation(Config.TestLibs.androidxRunner) + implementation(Config.TestLibs.androidxJunit) + implementation(Config.TestLibs.androidxTestRules) + implementation(Config.TestLibs.composeUiTestJunit4) } } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index f1f43c9c8bb..39ac3216610 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -1,8 +1,13 @@ package io.sentry.compose import androidx.compose.ui.Modifier +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.SemanticsPropertyReceiver public object SentryModifier { @@ -19,11 +24,35 @@ public object SentryModifier { ) @JvmStatic - public fun Modifier.sentryTag(tag: String): Modifier { - return semantics( - properties = { - this[SentryTag] = tag + public fun Modifier.sentryTag(tag: String): Modifier = + this then SentryTagModifierNodeElement(tag) + + private data class SentryTagModifierNodeElement(val tag: String) : + ModifierNodeElement(), SemanticsModifier { + + override val semanticsConfiguration: SemanticsConfiguration = + SemanticsConfiguration().also { + it[SentryTag] = tag } - ) + + override fun create(): SentryTagModifierNode = SentryTagModifierNode(tag) + + override fun update(node: SentryTagModifierNode) { + node.tag = tag + } + + override fun InspectorInfo.inspectableProperties() { + name = "sentryTag" + properties["tag"] = tag + } + } + + private class SentryTagModifierNode(var tag: String) : + Modifier.Node(), + SemanticsModifierNode { + + override fun SemanticsPropertyReceiver.applySemantics() { + this[SentryTag] = tag + } } } diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt new file mode 100644 index 00000000000..38aa2585d3f --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt @@ -0,0 +1,59 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.compose.SentryModifier.sentryTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryModifierComposeTest { + + companion object { + private const val TAG_VALUE = "ExampleTagValue" + } + + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager).addActivityIfNotPresent( + ComponentName( + appContext.packageName, + ComponentActivity::class.java.name + ) + ) + } + } + + @get:Rule(order = 2) + val rule = createComposeRule() + + @Test + fun sentryModifierAppliesTag() { + rule.setContent { + Box(modifier = Modifier.sentryTag(TAG_VALUE)) + } + rule.onNode( + SemanticsMatcher(TAG_VALUE) { + it.config.find { (key, _) -> key.name == SentryModifier.TAG }?.value == TAG_VALUE + } + ).assertExists() + } +} From 1eac2fccd717cd6d80f14863b1afd1ba9acc5548 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 7 Feb 2025 15:23:22 +0100 Subject: [PATCH 12/42] Cherry-pick: Session Replay: Fix various crashes and issues (#4135) (#4145) * Cherry-pick session replay fixes * Fix test --- CHANGELOG.md | 6 +- .../sentry/android/core/LifecycleWatcher.java | 8 +- .../android/core/LifecycleWatcherTest.kt | 6 +- .../io/sentry/android/replay/ReplayCache.kt | 5 +- .../android/replay/ReplayIntegration.kt | 72 +++++-- .../sentry/android/replay/ReplayLifecycle.kt | 58 ++++++ .../replay/capture/BaseCaptureStrategy.kt | 7 +- .../io/sentry/android/replay/util/Views.kt | 12 +- .../replay/video/SimpleVideoEncoder.kt | 5 +- .../sentry/android/replay/ReplayCacheTest.kt | 15 ++ .../android/replay/ReplayIntegrationTest.kt | 178 +++++++++++++++++- .../ReplayIntegrationWithRecorderTest.kt | 6 +- .../android/replay/ReplayLifecycleTest.kt | 120 ++++++++++++ 13 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dc74fdae405..0c08da8967c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ ### Fixes +- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135)) + - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file + - Fix `IllegalStateException` when registering `onDrawListener` + - Fix SIGABRT native crashes on Motorola devices when encoding a video - (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) - - This allows Composables that use this modifier to be skippable + - This allows Composables that use this modifier to be skippable ## 7.21.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 23072265eb0..8f7353f4e3d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -10,7 +10,6 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,7 +18,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); - private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; @@ -80,7 +78,6 @@ private void startSession() { final @Nullable Session currentSession = scope.getSession(); if (currentSession != null && currentSession.getStarted() != null) { lastUpdatedSession.set(currentSession.getStarted().getTime()); - isFreshSession.set(true); } } }); @@ -92,11 +89,8 @@ private void startSession() { hub.startSession(); } hub.getOptions().getReplayController().start(); - } else if (!isFreshSession.get()) { - // only resume if it's not a fresh session, which has been started in SentryAndroid.init - hub.getOptions().getReplayController().resume(); } - isFreshSession.set(false); + hub.getOptions().getReplayController().resume(); this.lastUpdatedSession.set(currentTimeMillis); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 1bc88961da4..4f4f46e63fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -254,7 +254,7 @@ class LifecycleWatcherTest { } @Test - fun `if the hub has already a fresh session running, doesn't resume replay`() { + fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { val watcher = fixture.getSUT( enableAppLifecycleBreadcrumbs = false, session = Session( @@ -276,7 +276,7 @@ class LifecycleWatcherTest { ) watcher.onStart(fixture.ownerMock) - verify(fixture.replayController, never()).resume() + verify(fixture.replayController).resume() } @Test @@ -293,7 +293,7 @@ class LifecycleWatcherTest { verify(fixture.replayController).pause() watcher.onStart(fixture.ownerMock) - verify(fixture.replayController).resume() + verify(fixture.replayController, times(2)).resume() watcher.onStop(fixture.ownerMock) verify(fixture.replayController, timeout(10000)).stop() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 88638e7e168..11d3b84897e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -53,7 +53,7 @@ public class ReplayCache( internal val frames = mutableListOf() private val ongoingSegment = LinkedHashMap() - private val ongoingSegmentFile: File? by lazy { + internal val ongoingSegmentFile: File? by lazy { if (replayCacheDir == null) { return@lazy null } @@ -273,6 +273,9 @@ public class ReplayCache( if (isClosed.get()) { return } + if (ongoingSegmentFile?.exists() != true) { + ongoingSegmentFile?.createNewFile() + } if (ongoingSegment.isEmpty()) { ongoingSegmentFile?.useLines { lines -> lines.associateTo(ongoingSegment) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c0b77abc2a5..655b3ca354b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -21,6 +21,11 @@ import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayState.CLOSED +import io.sentry.android.replay.ReplayState.PAUSED +import io.sentry.android.replay.ReplayState.RESUMED +import io.sentry.android.replay.ReplayState.STARTED +import io.sentry.android.replay.ReplayState.STOPPED import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment @@ -100,15 +105,15 @@ public class ReplayIntegration( Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) - private val isRecording = AtomicBoolean(false) + internal val isManualPause = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null + private val lifecycle = ReplayLifecycle() override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -151,15 +156,15 @@ public class ReplayIntegration( finalizePreviousReplay() } - override fun isRecording() = isRecording.get() + override fun isRecording() = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED + @Synchronized override fun start() { - // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { return } - if (isRecording.getAndSet(true)) { + if (!lifecycle.isAllowed(STARTED)) { options.logger.log( DEBUG, "Session replay is already being recorded, not starting a new one" @@ -183,19 +188,35 @@ public class ReplayIntegration( captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) registerRootViewListeners() + lifecycle.currentState = STARTED } override fun resume() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(false) + resumeInternal() + } + + @Synchronized + private fun resumeInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(RESUMED)) { + return + } + + if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED || + hub?.rateLimiter?.isActiveForCategory(All) == true || + hub?.rateLimiter?.isActiveForCategory(Replay) == true + ) { return } captureStrategy?.resume() recorder?.resume() + lifecycle.currentState = RESUMED } + @Synchronized override fun captureReplay(isTerminating: Boolean?) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -220,16 +241,24 @@ public class ReplayIntegration( override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter override fun pause() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(true) + pauseInternal() + } + + @Synchronized + private fun pauseInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { return } recorder?.pause() captureStrategy?.pause() + lifecycle.currentState = PAUSED } + @Synchronized override fun stop() { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(STOPPED)) { return } @@ -237,8 +266,8 @@ public class ReplayIntegration( recorder?.stop() gestureRecorder?.stop() captureStrategy?.stop() - isRecording.set(false) captureStrategy = null + lifecycle.currentState = STOPPED } override fun onScreenshotRecorded(bitmap: Bitmap) { @@ -257,8 +286,9 @@ public class ReplayIntegration( } } + @Synchronized override fun close() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(CLOSED)) { return } @@ -275,10 +305,11 @@ public class ReplayIntegration( recorder = null rootViewsSpy.close() replayExecutor.gracefullyShutdown(options) + lifecycle.currentState = CLOSED } override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -289,6 +320,10 @@ public class ReplayIntegration( captureStrategy?.onConfigurationChanged(recorderConfig) recorder?.start(recorderConfig) + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.pause() + } } override fun onConnectionStatusChanged(status: ConnectionStatus) { @@ -298,10 +333,10 @@ public class ReplayIntegration( } if (status == DISCONNECTED) { - pause() + pauseInternal() } else { // being positive for other states, even if it's NO_PERMISSION - resume() + resumeInternal() } } @@ -312,15 +347,18 @@ public class ReplayIntegration( } if (rateLimiter.isActiveForCategory(All) || rateLimiter.isActiveForCategory(Replay)) { - pause() + pauseInternal() } else { - resume() + resumeInternal() } } override fun onLowMemory() = Unit override fun onTouchEvent(event: MotionEvent) { + if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) { + return + } captureStrategy?.onTouchEvent(event) } @@ -336,7 +374,7 @@ public class ReplayIntegration( hub?.rateLimiter?.isActiveForCategory(Replay) == true ) ) { - pause() + pauseInternal() } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt new file mode 100644 index 00000000000..fba95fcb415 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt @@ -0,0 +1,58 @@ +package io.sentry.android.replay + +internal enum class ReplayState { + /** + * Initial state of a Replay session. This is the state when ReplayIntegration is constructed + * but has not been started yet. + */ + INITIAL, + + /** + * Started state for a Replay session. This state is reached after the start() method is called + * and the recording is initialized successfully. + */ + STARTED, + + /** + * Resumed state for a Replay session. This state is reached after resume() is called on an + * already started recording. + */ + RESUMED, + + /** + * Paused state for a Replay session. This state is reached after pause() is called on a + * resumed recording. + */ + PAUSED, + + /** + * Stopped state for a Replay session. This state is reached after stop() is called. + * The recording can be started again from this state. + */ + STOPPED, + + /** + * Closed state for a Replay session. This is the terminal state reached after close() is called. + * No further state transitions are possible after this. + */ + CLOSED; +} + +/** + * Class to manage state transitions for ReplayIntegration + */ +internal class ReplayLifecycle { + @field:Volatile + internal var currentState = ReplayState.INITIAL + + fun isAllowed(newState: ReplayState): Boolean = when (currentState) { + ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.STARTED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.RESUMED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.PAUSED -> newState == ReplayState.RESUMED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.STOPPED -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.CLOSED -> false + } + + fun isTouchRecordingAllowed(): Boolean = currentState == ReplayState.STARTED || currentState == ReplayState.RESUMED +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fbc80565b1b..9caf92fa20f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -5,6 +5,7 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub +import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER @@ -183,7 +184,11 @@ internal abstract class BaseCaptureStrategy( task() } } else { - task() + try { + task() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $TAG.runInBackground", e) + } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index f3e667dc320..b51f2f98475 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -184,12 +184,20 @@ internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListen if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.addOnDrawListener(listener) + try { + viewTreeObserver.addOnDrawListener(listener) + } catch (e: IllegalStateException) { + // viewTreeObserver is already dead + } } internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.removeOnDrawListener(listener) + try { + viewTreeObserver.removeOnDrawListener(listener) + } catch (e: IllegalStateException) { + // viewTreeObserver is already dead + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 211decc098d..0a535a439c7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -157,7 +157,10 @@ internal class SimpleVideoEncoder( fun encode(image: Bitmap) { // it seems that Xiaomi devices have problems with hardware canvas, so we have to use // lockCanvas instead https://stackoverflow.com/a/73520742 - val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) { + val canvas = if ( + Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || + Build.MANUFACTURER.contains("motorola", ignoreCase = true) + ) { surface?.lockCanvas(null) } else { surface?.lockHardwareCanvas() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index b2c8836d40f..a3e17d4f732 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -285,6 +285,21 @@ class ReplayCacheTest { assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) } + @Test + fun `when file does not exist upon persisting creates it`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId + ) + + replayCache.ongoingSegmentFile?.delete() + + replayCache.persistSegmentValues("key", "value") + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key=value", segmentValues[0]) + } + @Test fun `stores segment key value pairs`() { val replayId = SentryId() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 4183a780b8e..353b11d8f66 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -277,6 +277,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.pause() replay.resume() verify(captureStrategy).resume() @@ -646,6 +647,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) replay.onConnectionStatusChanged(CONNECTED) verify(recorder).resume() @@ -677,16 +679,190 @@ class ReplayIntegrationTest { context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }, - isRateLimited = false + isRateLimited = true ) replay.register(fixture.hub, fixture.options) replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) replay.onRateLimitChanged(fixture.rateLimiter) verify(recorder).resume() } + @Test + fun `closed replay cannot be started`() { + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + replay.start() + replay.close() + + replay.start() + + assertFalse(replay.isRecording) + } + + @Test + fun `if recording is paused in configChanges re-pauses it again`() { + var configChanged = false + val recorderConfig = mock() + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + recorderConfigProvider = { configChanged = it; recorderConfig } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onConfigurationChanged(mock()) + + verify(recorder).stop() + verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) + verify(recorder, times(2)).start(eq(recorderConfig)) + verify(recorder, times(2)).pause() + assertTrue(configChanged) + } + + @Test + fun `onTouchEvent does nothing when not started or resumed`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + } + + @Test + fun `when paused manually onConnectionStatusChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) + replay.pause() + replay.onConnectionStatusChanged(CONNECTED) + + verify(recorder, never()).resume() + } + + @Test + fun `when paused manually onRateLimitChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + replay.pause() + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) + replay.onRateLimitChanged(fixture.rateLimiter) + + verify(recorder, never()).resume() + } + + @Test + fun `when rate limit is active manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when no connection manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isOffline = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when already paused does not pause again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.pause() + + verify(recorder).pause() + } + + @Test + fun `when already resumed does not resume again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + replay.resume() + + verify(recorder).resume() + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy { return SessionCaptureStrategy( options, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index ae817a17596..e2491d3796a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -127,12 +127,12 @@ class ReplayIntegrationWithRecorderTest { replay.start() assertEquals(STARTED, recorder.state) - replay.resume() - assertEquals(RESUMED, recorder.state) - replay.pause() assertEquals(PAUSED, recorder.state) + replay.resume() + assertEquals(RESUMED, recorder.state) + replay.stop() assertEquals(STOPPED, recorder.state) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt new file mode 100644 index 00000000000..c9892374526 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt @@ -0,0 +1,120 @@ +package io.sentry.android.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReplayLifecycleTest { + @Test + fun `verify initial state`() { + val lifecycle = ReplayLifecycle() + assertEquals(ReplayState.INITIAL, lifecycle.currentState) + } + + @Test + fun `test transitions from INITIAL state`() { + val lifecycle = ReplayLifecycle() + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + } + + @Test + fun `test transitions from STARTED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STARTED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from RESUMED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.RESUMED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from PAUSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.PAUSED + + assertTrue(lifecycle.isAllowed(ReplayState.RESUMED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from STOPPED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STOPPED + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from CLOSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.CLOSED + + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + assertFalse(lifecycle.isAllowed(ReplayState.CLOSED)) + } + + @Test + fun `test touch recording is allowed only in STARTED and RESUMED states`() { + val lifecycle = ReplayLifecycle() + + // Initial state doesn't allow touch recording + assertFalse(lifecycle.isTouchRecordingAllowed()) + + // STARTED state allows touch recording + lifecycle.currentState = ReplayState.STARTED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // RESUMED state allows touch recording + lifecycle.currentState = ReplayState.RESUMED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // Other states don't allow touch recording + val otherStates = listOf( + ReplayState.INITIAL, + ReplayState.PAUSED, + ReplayState.STOPPED, + ReplayState.CLOSED + ) + + otherStates.forEach { state -> + lifecycle.currentState = state + assertFalse(lifecycle.isTouchRecordingAllowed()) + } + } +} From d714dc82d38b5663d43ec5ee72aa590303b0f29f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 11 Feb 2025 15:56:22 +0100 Subject: [PATCH 13/42] feat(android-ndk): add api for getting debug images by addresses (#4159) * feat(android-ndk): add api for getting debug images by addresses (#4089) --------- Co-authored-by: Sentry Github Bot Co-authored-by: Markus Hintersteiner * Update Changelog * Format code * Fix switch sync/data classes to match 7.x.x --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 4 + .../api/sentry-android-core.api | 1 + .../android/core/IDebugImagesLoader.java | 4 + .../android/core/NoOpDebugImagesLoader.java | 6 + .../android/core/SentryAndroidOptionsTest.kt | 2 + sentry-android-ndk/api/sentry-android-ndk.api | 1 + .../sentry/android/ndk/DebugImagesLoader.java | 91 +++++++++++++- .../android/ndk/DebugImagesLoaderTest.kt | 111 +++++++++++++++++- 8 files changed, 215 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c08da8967c..f00519002ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) - This allows Composables that use this modifier to be skippable +### Features + +- (Internal) Add API to filter native debug images based on stacktrace addresses ([#4159](https://github.com/getsentry/sentry-java/pull/4159)) + ## 7.21.0 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 99ca27dc733..ca1f067552a 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -208,6 +208,7 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i public abstract interface class io/sentry/android/core/IDebugImagesLoader { public abstract fun clearDebugImages ()V public abstract fun loadDebugImages ()Ljava/util/List; + public abstract fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; } public final class io/sentry/android/core/InternalSentrySdk { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java index 902f7efc2b5..7b98147aab8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; @@ -11,5 +12,8 @@ public interface IDebugImagesLoader { @Nullable List loadDebugImages(); + @Nullable + Set loadDebugImagesForAddresses(Set addresses); + void clearDebugImages(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java index 70451972a76..193b7342193 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.Nullable; final class NoOpDebugImagesLoader implements IDebugImagesLoader { @@ -19,6 +20,11 @@ public static NoOpDebugImagesLoader getInstance() { return null; } + @Override + public @Nullable Set loadDebugImagesForAddresses(Set addresses) { + return null; + } + @Override public void clearDebugImages() {} } 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 aa266d5c7a2..7bcc35b6b56 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 @@ -182,6 +182,8 @@ class SentryAndroidOptionsTest { private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null + override fun loadDebugImagesForAddresses(addresses: Set?): Set? = null + override fun clearDebugImages() {} } } diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index e8f838ce8b4..cbd2e308bd6 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -10,6 +10,7 @@ public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/c public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/ndk/NativeModuleListLoader;)V public fun clearDebugImages ()V public fun loadDebugImages ()Ljava/util/List; + public fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; } public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObserverAdapter { diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java index cb38db498a6..a8574157775 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java @@ -7,7 +7,9 @@ import io.sentry.protocol.DebugImage; import io.sentry.util.Objects; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; @@ -22,7 +24,7 @@ public final class DebugImagesLoader implements IDebugImagesLoader { private final @NotNull NativeModuleListLoader moduleListLoader; - private static @Nullable List debugImages; + private static volatile @Nullable List debugImages; /** we need to lock it because it could be called from different threads */ private static final @NotNull Object debugImagesLock = new Object(); @@ -60,7 +62,92 @@ public DebugImagesLoader( return debugImages; } - /** Clears the caching of debug images on sentry-native and here. */ + /** + * Loads debug images for the given set of addresses. + * + * @param addresses Set of memory addresses to find debug images for + * @return Set of matching debug images, or null if debug images couldn't be loaded + */ + public @Nullable Set loadDebugImagesForAddresses( + final @NotNull Set addresses) { + synchronized (debugImagesLock) { + final @Nullable List allDebugImages = loadDebugImages(); + if (allDebugImages == null) { + return null; + } + if (addresses.isEmpty()) { + return null; + } + + final Set referencedImages = filterImagesByAddresses(allDebugImages, addresses); + if (referencedImages.isEmpty()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No debug images found for any of the %d addresses.", + addresses.size()); + return null; + } + + return referencedImages; + } + } + + /** + * Finds all debug image containing the given addresses. Assumes that the images are sorted by + * address, which should always be true on Linux/Android and Windows platforms + * + * @return All matching debug images or null if none are found + */ + private @NotNull Set filterImagesByAddresses( + final @NotNull List images, final @NotNull Set addresses) { + final Set result = new HashSet<>(); + + for (int i = 0; i < images.size(); i++) { + final @NotNull DebugImage image = images.get(i); + final @Nullable DebugImage nextDebugImage = + (i + 1) < images.size() ? images.get(i + 1) : null; + final @Nullable String nextDebugImageAddress = + nextDebugImage != null ? nextDebugImage.getImageAddr() : null; + + for (final @NotNull String rawAddress : addresses) { + try { + final long address = Long.parseLong(rawAddress.replace("0x", ""), 16); + + final @Nullable String imageAddress = image.getImageAddr(); + if (imageAddress != null) { + try { + final long imageStart = Long.parseLong(imageAddress.replace("0x", ""), 16); + final long imageEnd; + + final @Nullable Long imageSize = image.getImageSize(); + if (imageSize != null) { + imageEnd = imageStart + imageSize; + } else if (nextDebugImageAddress != null) { + imageEnd = Long.parseLong(nextDebugImageAddress.replace("0x", ""), 16); + } else { + imageEnd = Long.MAX_VALUE; + } + if (address >= imageStart && address < imageEnd) { + result.add(image); + // once image is added we can skip the remaining addresses and go straight to the + // next + // image + break; + } + } catch (NumberFormatException e) { + // ignored, invalid debug image address + } + } + } catch (NumberFormatException e) { + // ignored, invalid address supplied + } + } + } + return result; + } + @Override public void clearDebugImages() { synchronized (debugImagesLock) { diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt index db584c814f6..4d14caa695d 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt @@ -5,8 +5,8 @@ import io.sentry.protocol.DebugImage import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.lang.RuntimeException import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -17,11 +17,13 @@ class DebugImagesLoaderTest { val options = SentryAndroidOptions() fun getSut(): DebugImagesLoader { - return DebugImagesLoader(options, nativeLoader) + val loader = DebugImagesLoader(options, nativeLoader) + loader.clearDebugImages() + return loader } } - private val fixture = Fixture() + private var fixture = Fixture() @Test fun `get images returns image list`() { @@ -78,4 +80,107 @@ class DebugImagesLoaderTest { assertNull(sut.cachedDebugImages) } + + @Test + fun `find images by address`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + val image3 = DebugImage().apply { + imageAddr = "0x3000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2, image3)) + + val result = sut.loadDebugImagesForAddresses( + setOf("0x1500", "0x2500") + ) + + assertNotNull(result) + assertEquals(2, result.size) + assertTrue(result.any { it.imageAddr == image1.imageAddr }) + assertTrue(result.any { it.imageAddr == image2.imageAddr }) + } + + @Test + fun `find images with invalid addresses are not added to the result`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2)) + + val hexAddresses = setOf("0xINVALID", "0x1500") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertEquals(1, result!!.size) + } + + @Test + fun `find images by address returns null if result is empty`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2)) + + val hexAddresses = setOf("0x100", "0x10500") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertNull(result) + } + + @Test + fun `invalid image addresses are ignored for loadDebugImagesForAddresses`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0xNotANumber" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = null + } + + val image3 = DebugImage().apply { + imageAddr = "0x5000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2, image3)) + + val hexAddresses = setOf("0x100", "0x2000", "0x2000", "0x5000") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertNotNull(result) + assertEquals(2, result.size) + } } From 8be05874241033eb0a32fac7c8f0de2a91836d03 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 11 Feb 2025 16:06:56 +0000 Subject: [PATCH 14/42] release: 7.22.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00519002ba..9bfc5eeeb74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index e423d73f7f9..944f3ae3a02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.21.0 +versionName=7.22.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From f10c73cc2425b8be78b7689a795e44bd32d90f2e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 10 Mar 2025 14:00:37 +0100 Subject: [PATCH 15/42] Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running (#4216) * Ensure app start type is set, even when ActivityLifecycleIntegration is not activated * Update Changelog * Add proper tests * Add code comments * Unify handling * Move all app start handling to AppStartMetrics * Make tests happy * Fix flaky RateLimiter test (#4100) * changed RateLimiterTest `close cancels the timer` to use reflection * Update sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java Co-authored-by: Stefano * Address PR feedback * Fix post-merge conflict * Format code * Address PR feedback * Address PR feedback * Update sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java --------- Co-authored-by: Stefano Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 6 + .../api/sentry-android-core.api | 6 +- .../core/ActivityLifecycleIntegration.java | 26 +-- .../io/sentry/android/core/SentryAndroid.java | 2 +- .../core/SentryPerformanceProvider.java | 48 +--- .../core/performance/AppStartMetrics.java | 164 +++++++------ .../core/ActivityLifecycleIntegrationTest.kt | 138 +---------- .../PerformanceAndroidEventProcessorTest.kt | 3 +- .../core/SentryPerformanceProviderTest.kt | 27 +-- .../core/performance/AppStartMetricsTest.kt | 215 ++++++++++++++---- .../io/sentry/transport/RateLimiterTest.kt | 21 +- 11 files changed, 311 insertions(+), 345 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfc5eeeb74..983bbc29685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) + ## 7.22.0 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ca1f067552a..8096a5058e3 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -447,15 +447,15 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z - public fun isColdStartValid ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityStarted (Landroid/app/Activity;)V public fun onAppStartSpansSent ()V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V - public fun registerApplicationForegroundCheck (Landroid/app/Application;)V - public fun restartAppStart (J)V + public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 0912051dd7d..5ddf706d16d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -397,7 +397,6 @@ public synchronized void onActivityCreated( if (!isAllActivityCallbacksAvailable) { onActivityPreCreated(activity, savedInstanceState); } - setColdStart(savedInstanceState); if (hub != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); hub.configureScope(scope -> scope.setScreen(activityClassName)); @@ -554,15 +553,13 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) { // if the activity is opened again and not in memory, transactions will be created normally. activitiesWithOngoingTransactions.remove(activity); - if (activitiesWithOngoingTransactions.isEmpty()) { + if (activitiesWithOngoingTransactions.isEmpty() && !activity.isChangingConfigurations()) { clear(); } } private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - lastPausedUptimeMillis = 0; activityLifecycleMap.clear(); } @@ -705,27 +702,6 @@ WeakHashMap getTtfdSpanMap() { return ttfdSpanMap; } - private void setColdStart(final @Nullable Bundle savedInstanceState) { - if (!firstActivityCreated) { - final @NotNull TimeSpan appStartSpan = AppStartMetrics.getInstance().getAppStartTimeSpan(); - // If the app start span already started and stopped, it means the app restarted without - // killing the process, so we are in a warm start - // If the app has an invalid cold start, it means it was started in the background, like - // via BroadcastReceiver, so we consider it a warm start - if ((appStartSpan.hasStarted() && appStartSpan.hasStopped()) - || (!AppStartMetrics.getInstance().isColdStartValid())) { - AppStartMetrics.getInstance().restartAppStart(lastPausedUptimeMillis); - AppStartMetrics.getInstance().setAppStartType(AppStartMetrics.AppStartType.WARM); - } else { - AppStartMetrics.getInstance() - .setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); - } - } - } - private @NotNull String getTtidDesc(final @NotNull String activityName) { return activityName + " initial display"; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index adeb451332a..9fee04f2515 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -152,7 +152,7 @@ public static synchronized void init( } } if (context.getApplicationContext() instanceof Application) { - appStartMetrics.registerApplicationForegroundCheck( + appStartMetrics.registerLifecycleCallbacks( (Application) context.getApplicationContext()); } final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 0aa946c2553..ade1826c2c1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -3,17 +3,13 @@ import static io.sentry.Sentry.APP_START_PROFILING_CONFIG_FILE_NAME; import android.annotation.SuppressLint; -import android.app.Activity; import android.app.Application; import android.content.Context; import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Build; -import android.os.Handler; -import android.os.Looper; import android.os.Process; import android.os.SystemClock; -import androidx.annotation.NonNull; import io.sentry.ILogger; import io.sentry.ITransactionProfiler; import io.sentry.JsonSerializer; @@ -22,9 +18,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.TracesSamplingDecision; -import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; -import io.sentry.android.core.performance.ActivityLifecycleCallbacksAdapter; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import java.io.BufferedReader; @@ -33,7 +27,6 @@ import java.io.FileNotFoundException; import java.io.InputStreamReader; import java.io.Reader; -import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -185,8 +178,9 @@ private void onAppLaunched( // performance v2: Uses Process.getStartUptimeMillis() // requires API level 24+ - if (buildInfoProvider.getSdkInfoVersion() < android.os.Build.VERSION_CODES.N) { - return; + if (buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); } if (context instanceof Application) { @@ -196,40 +190,6 @@ private void onAppLaunched( return; } - final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); - appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); - appStartMetrics.registerApplicationForegroundCheck(app); - - final AtomicBoolean firstDrawDone = new AtomicBoolean(false); - - activityCallback = - new ActivityLifecycleCallbacksAdapter() { - @Override - public void onActivityStarted(@NonNull Activity activity) { - if (firstDrawDone.get()) { - return; - } - if (activity.getWindow() != null) { - FirstDrawDoneListener.registerForNextDraw( - activity, () -> onAppStartDone(), buildInfoProvider); - } else { - new Handler(Looper.getMainLooper()).post(() -> onAppStartDone()); - } - } - }; - - app.registerActivityLifecycleCallbacks(activityCallback); - } - - synchronized void onAppStartDone() { - final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - appStartMetrics.getSdkInitTimeSpan().stop(); - appStartMetrics.getAppStartTimeSpan().stop(); - - if (app != null) { - if (activityCallback != null) { - app.unregisterActivityLifecycleCallbacks(activityCallback); - } - } + appStartMetrics.registerLifecycleCallbacks(app); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 2e249d6dccf..20a85584866 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -11,17 +11,20 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.sentry.ITransactionProfiler; -import io.sentry.SentryDate; -import io.sentry.SentryNanotimeDate; +import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -30,6 +33,9 @@ * An in-memory representation for app-metrics during app start. As the SDK can't be initialized * that early, we can't use transactions or spans directly. Thus simple TimeSpans are used and later * transformed into SDK specific txn/span data structures. + * + *

This class is also responsible for - determining the app start type (cold, warm) - determining + * if the app was launched in foreground */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { @@ -45,7 +51,7 @@ public enum AppStartType { private static volatile @Nullable AppStartMetrics instance; private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground = false; + private boolean appLaunchedInForeground; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -54,10 +60,10 @@ public enum AppStartType { private final @NotNull List activityLifecycles; private @Nullable ITransactionProfiler appStartProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; - private @Nullable SentryDate onCreateTime = null; - private boolean appLaunchTooLong = false; private boolean isCallbackRegistered = false; private boolean shouldSendStartMeasurements = true; + private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); + private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); public static @NotNull AppStartMetrics getInstance() { @@ -116,10 +122,6 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } - public boolean isColdStartValid() { - return appLaunchedInForeground && !appLaunchTooLong; - } - @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; @@ -153,17 +155,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements; - } - - public void restartAppStart(final long uptimeMillis) { - shouldSendStartMeasurements = true; - appLaunchTooLong = false; - appLaunchedInForeground = true; - appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(uptimeMillis); - CLASS_LOADED_UPTIME_MS = appStartSpan.getStartUptimeMs(); + return shouldSendStartMeasurements && appLaunchedInForeground; } public long getClassLoadedUptimeMs() { @@ -176,20 +168,27 @@ public long getClassLoadedUptimeMs() { */ public @NotNull TimeSpan getAppStartTimeSpanWithFallback( final @NotNull SentryAndroidOptions options) { - // If the app launch took too long or it was launched in the background we return an empty span - if (!isColdStartValid()) { - return new TimeSpan(); - } - if (options.isEnablePerformanceV2()) { - // Only started when sdk version is >= N - final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); - if (appStartSpan.hasStarted()) { - return appStartSpan; + // If the app start type was never determined or app wasn't launched in foreground, + // the app start is considered invalid + if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted() + && appStartSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + final @NotNull TimeSpan sdkInitTimeSpan = getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasStarted() + && sdkInitTimeSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return sdkInitTimeSpan; } } - // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return new TimeSpan(); } @TestOnly @@ -205,11 +204,11 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; - appLaunchTooLong = false; appLaunchedInForeground = false; - onCreateTime = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; + firstDrawDone.set(false); + activeActivitiesCounter.set(0); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -247,7 +246,23 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.registerApplicationForegroundCheck(application); + instance.registerLifecycleCallbacks(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); } } @@ -256,7 +271,7 @@ public static void onApplicationCreate(final @NotNull Application application) { * * @param application The application object to register the callback to */ - public void registerApplicationForegroundCheck(final @NotNull Application application) { + public void registerLifecycleCallbacks(final @NotNull Application application) { if (isCallbackRegistered) { return; } @@ -267,15 +282,15 @@ public void registerApplicationForegroundCheck(final @NotNull Application applic // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); } - private void checkCreateTimeOnMain(final @NotNull Application application) { + private void checkCreateTimeOnMain() { new Handler(Looper.getMainLooper()) .post( () -> { // if no activity has ever been created, app was launched in background - if (onCreateTime == null) { + if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground = false; // we stop the app start profiler, as it's useless and likely to timeout @@ -284,43 +299,54 @@ private void checkCreateTimeOnMain(final @NotNull Application application) { appStartProfiler = null; } } - application.unregisterActivityLifecycleCallbacks(instance); }); } @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - // An activity already called onCreate() - if (!appLaunchedInForeground || onCreateTime != null) { + final long nowUptimeMs = SystemClock.uptimeMillis(); + + // the first activity determines the app start type + if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { + // If the app (process) was launched more than 1 minute ago, it's likely wrong + final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); + if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + appStartType = AppStartType.WARM; + + shouldSendStartMeasurements = true; + appStartSpan.reset(); + appStartSpan.start(); + appStartSpan.setStartedAt(nowUptimeMs); + CLASS_LOADED_UPTIME_MS = nowUptimeMs; + } else { + appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + } + } + appLaunchedInForeground = true; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { return; } - onCreateTime = new SentryNanotimeDate(); - - final long spanStartMillis = appStartSpan.getStartTimestampMs(); - final long spanEndMillis = - appStartSpan.hasStopped() - ? appStartSpan.getProjectedStopTimestampMs() - : System.currentTimeMillis(); - final long durationMillis = spanEndMillis - spanStartMillis; - // If the app was launched more than 1 minute ago, it's likely wrong - if (durationMillis > TimeUnit.MINUTES.toMillis(1)) { - appLaunchTooLong = true; + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(), new BuildInfoProvider(NoOpLogger.getInstance())); + } else { + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn()); } } - /** - * Called by instrumentation - * - * @param application The application object where onCreate was called on - * @noinspection unused - */ - public static void onApplicationPostCreate(final @NotNull Application application) { - final long now = SystemClock.uptimeMillis(); - - final @NotNull AppStartMetrics instance = getInstance(); - if (instance.applicationOnCreate.hasNotStopped()) { - instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); - instance.applicationOnCreate.setStoppedAt(now); + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + // if the app is moving into background + // as the next Activity is considered like a new app start + if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appLaunchedInForeground = false; + shouldSendStartMeasurements = true; + firstDrawDone.set(false); } } @@ -354,4 +380,12 @@ public static void onContentProviderPostCreate(final @NotNull ContentProvider co measurement.setStoppedAt(now); } } + + synchronized void onFirstFrameDrawn() { + if (!firstDrawDone.getAndSet(true)) { + final @NotNull AppStartMetrics appStartMetrics = getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index b212ed2feab..c14d6c82c4a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -54,7 +54,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import java.util.Date import java.util.concurrent.Future -import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -94,7 +93,10 @@ class ActivityLifecycleIntegrationTest { whenever(hub.options).thenReturn(options) - AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.start() + // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() @@ -594,45 +596,6 @@ class ActivityLifecycleIntegrationTest { verify(ttfdReporter, never()).registerFullyDrawnListener(any()) } - @Test - fun `App start is Cold when savedInstanceState is null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `App start is Warm when savedInstanceState is not null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `Do not overwrite App start type after set`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - @Test fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() @@ -883,86 +846,6 @@ class ActivityLifecycleIntegrationTest { ) } - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start warm span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, fixture.bundle) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start cold span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.cold") - assertEquals(span.description, "Cold Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started more than 1 minute ago, start app with Warm start`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started in background, start app with Warm start`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - @Test fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() @@ -1467,7 +1350,6 @@ class ActivityLifecycleIntegrationTest { assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) sut.onActivityCreated(activity, null) - assertNotNull(sut.appStartSpan) sut.onActivityPostCreated(activity, null) assertTrue(activityLifecycleSpan.onCreate.hasStopped()) @@ -1556,15 +1438,6 @@ class ActivityLifecycleIntegrationTest { // lastPausedUptimeMillis is set to current SystemClock.uptimeMillis() val lastUptimeMillis = sut.getProperty("lastPausedUptimeMillis") assertNotEquals(0, lastUptimeMillis) - - sut.onActivityCreated(activity, null) - // AppStartMetrics app start time is set to Activity preCreated timestamp - assertEquals(lastUptimeMillis, appStartMetrics.appStartTimeSpan.startUptimeMs) - // AppStart type is considered warm - assertEquals(AppStartType.WARM, appStartMetrics.appStartType) - - // Activity appStart span timestamp is the same of AppStartMetrics.appStart timestamp - assertEquals(sut.appStartSpan!!.startDate.nanoTimestamp(), appStartMetrics.getAppStartTimeSpanWithFallback(fixture.options).startTimestamp!!.nanoTimestamp()) } private fun SentryTracer.isFinishing() = getProperty("finishStatus").getProperty("isFinishing") @@ -1578,6 +1451,9 @@ class ActivityLifecycleIntegrationTest { private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) { // set by SentryPerformanceProvider so forcing it here + AppStartMetrics.getInstance().appStartType = AppStartType.COLD + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 35e0f5257bf..c0d18e5db75 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -75,7 +75,7 @@ class PerformanceAndroidEventProcessorTest { null, null ).also { - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + AppStartMetrics.getInstance().onActivityCreated(mock(), if (coldStart) null else mock()) } @BeforeTest @@ -225,6 +225,7 @@ class PerformanceAndroidEventProcessorTest { fun `adds app start metrics to app start txn`() { // given some app start metrics val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.isAppLaunchedInForeground = true appStartMetrics.appStartType = AppStartType.COLD appStartMetrics.appStartTimeSpan.setStartedAt(123) appStartMetrics.appStartTimeSpan.setStoppedAt(456) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 9f868d701b4..5e816f6ca50 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.content.pm.ProviderInfo import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,7 +17,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq 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.annotation.Config @@ -48,6 +48,7 @@ class SentryPerformanceProviderTest { val providerInfo = ProviderInfo() val logger = mock() lateinit var configFile: File + var activityLifecycleCallbacks: MutableList = mutableListOf() fun getSut(sdkVersion: Int = Build.VERSION_CODES.S, authority: String = AUTHORITY, handleFile: ((config: File) -> Unit)? = null): SentryPerformanceProvider { val buildInfoProvider: BuildInfoProvider = mock() @@ -56,7 +57,14 @@ class SentryPerformanceProviderTest { whenever(mockContext.applicationContext).thenReturn(mockContext) configFile = File(sentryCache, Sentry.APP_START_PROFILING_CONFIG_FILE_NAME) handleFile?.invoke(configFile) - + whenever(mockContext.registerActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.add(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } + whenever(mockContext.unregisterActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.remove(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } providerInfo.authority = authority return SentryPerformanceProvider(logger, buildInfoProvider).apply { attachInfo(mockContext, providerInfo) @@ -104,24 +112,11 @@ class SentryPerformanceProviderTest { @Test fun `provider sets both appstart and sdk init start + end times`() { val provider = fixture.getSut() - provider.onAppStartDone() + provider.onCreate() val metrics = AppStartMetrics.getInstance() assertTrue(metrics.appStartTimeSpan.hasStarted()) - assertTrue(metrics.appStartTimeSpan.hasStopped()) - assertTrue(metrics.sdkInitTimeSpan.hasStarted()) - assertTrue(metrics.sdkInitTimeSpan.hasStopped()) - } - - @Test - fun `provider properly registers and unregisters ActivityLifecycleCallbacks`() { - val provider = fixture.getSut() - - // It register once for the provider itself and once for the appStartMetrics - verify(fixture.mockContext, times(2)).registerActivityLifecycleCallbacks(any()) - provider.onAppStartDone() - verify(fixture.mockContext).unregisterActivityLifecycleCallbacks(any()) } //region app start profiling diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index d8b9e727e20..d36ff01a5fb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -1,9 +1,12 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.Application import android.content.ContentProvider import android.os.Build +import android.os.Bundle import android.os.Looper +import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ITransactionProfiler import io.sentry.android.core.SentryAndroidOptions @@ -75,6 +78,7 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled and app start time span is started, appStartTimeSpanWithFallback returns it`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.WARM appStartTimeSpan.start() val options = SentryAndroidOptions().apply { @@ -88,7 +92,12 @@ class AppStartMetricsTest { @Test fun `if perf-2 is disabled but app start time span has started, appStartTimeSpanWithFallback returns the sdk init span instead`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } + appStartTimeSpan.setStartedAt(123) val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false @@ -101,8 +110,11 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled but app start time span has not started, appStartTimeSpanWithFallback returns the sdk init span instead`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - assertTrue(appStartTimeSpan.hasNotStarted()) + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = true @@ -121,6 +133,8 @@ class AppStartMetricsTest { @Test fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { AppStartMetrics.getInstance().isAppLaunchedInForeground = false + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) @@ -136,19 +150,50 @@ class AppStartMetricsTest { } @Test - fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() - assertTrue(appStartTimeSpan.hasStarted()) - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + fun `if app is launched in background, but an activity launches later, a new warm start is reported with correct timings`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) - val options = SentryAndroidOptions().apply { - isEnablePerformanceV2 = true - } + // when the looper runs + Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) - assertFalse(timeSpan.hasStarted()) + // but no activity creation happened + // then the app wasn't launched in foreground and nothing should be sent + assertFalse(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements()) + + val now = TimeUnit.MINUTES.toMillis(2) + 1234567 + SystemClock.setCurrentTimeMillis(now) + + // once an activity launches + AppStartMetrics.getInstance().onActivityCreated(mock(), null) + + // then it should restart the timespan + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `if app is launched in background, the first created activity assumes a warm start`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + metrics.registerLifecycleCallbacks(mock()) + + // when the handler callback is executed and no activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // isAppLaunchedInForeground should be false + assertFalse(metrics.isAppLaunchedInForeground) + + // but when the first activity launches + metrics.onActivityCreated(mock(), null) + + // then a warm start should be set + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test @@ -172,14 +217,15 @@ class AppStartMetricsTest { @Test fun `if activity is never started, returns an empty span`() { - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) + val timeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) assertFalse(timeSpan.hasStarted()) } @@ -189,7 +235,7 @@ class AppStartMetricsTest { whenever(profiler.isRunning).thenReturn(true) AppStartMetrics.getInstance().appStartProfiler = profiler - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -203,7 +249,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().appStartProfiler = profiler AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -231,33 +277,26 @@ class AppStartMetricsTest { @Test fun `when multiple registerApplicationForegroundCheck, only one callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application, times(1)).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + verify( + application, + times(1) + ).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } @Test fun `when registerApplicationForegroundCheck, a callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } - @Test - fun `when registerApplicationForegroundCheck, a job is posted on main thread to unregistered the callback`() { - val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - verify(application, never()).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(application).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - } - @Test fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // Main thread performs the check and sets the flag to false if no activity was created Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -268,7 +307,7 @@ class AppStartMetricsTest { fun `registerApplicationForegroundCheck keeps foreground state to true if an activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) @@ -277,12 +316,6 @@ class AppStartMetricsTest { assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } - @Test - fun `isColdStartValid is false if app was launched in background`() { - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - assertFalse(AppStartMetrics.getInstance().isColdStartValid) - } - @Test fun `isColdStartValid is false if app launched in more than 1 minute`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -291,7 +324,6 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - assertFalse(AppStartMetrics.getInstance().isColdStartValid) } @Test @@ -307,19 +339,104 @@ class AppStartMetricsTest { } @Test - fun `restartAppStart set measurement flag and clear internal lists`() { + fun `a warm start gets reported after a cold start`() { val appStartMetrics = AppStartMetrics.getInstance() + + // when the first activity launches and gets destroyed + val activity0 = mock() + whenever(activity0.isChangingConfigurations).thenReturn(false) + appStartMetrics.onActivityCreated(activity0, null) + + // then the app start type should be cold and measurements should be sent + assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + + // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - appStartMetrics.isAppLaunchedInForeground = false assertFalse(appStartMetrics.shouldSendStartMeasurements()) - assertFalse(appStartMetrics.isColdStartValid) - appStartMetrics.restartAppStart(10) + appStartMetrics.onActivityDestroyed(activity0) + // then it should reset sending the measurements for the next warm activity + appStartMetrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) assertTrue(appStartMetrics.shouldSendStartMeasurements()) - assertTrue(appStartMetrics.isColdStartValid) - assertTrue(appStartMetrics.appStartTimeSpan.hasStarted()) - assertTrue(appStartMetrics.appStartTimeSpan.hasNotStopped()) - assertEquals(10, appStartMetrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + + assertFalse(metrics.appStartTimeSpan.hasStopped()) + assertFalse(metrics.sdkInitTimeSpan.hasStopped()) + + metrics.onFirstFrameDrawn() + + assertTrue(metrics.appStartTimeSpan.hasStopped()) + assertTrue(metrics.sdkInitTimeSpan.hasStopped()) + } + + @Test + fun `Sets app launch type to cold`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), mock()) + + // then the app start is still considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm if process init was too long ago`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + val app = mock() + metrics.registerLifecycleCallbacks(app) + + // when an activity is created later with a null bundle + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), mock()) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), null) + + // then the app start is still considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } } diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 8d8fb9601ef..9fe0cd40877 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -28,6 +28,8 @@ import io.sentry.metrics.EncodedMetrics import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User +import io.sentry.test.getProperty +import io.sentry.test.injectForField import org.awaitility.kotlin.await import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -37,6 +39,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import java.io.File +import java.util.Timer import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.Test @@ -412,18 +415,16 @@ class RateLimiterTest { @Test fun `close cancels the timer`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 1, 2001) - - val applied = AtomicBoolean(true) - rateLimiter.addRateLimitObserver { - applied.set(rateLimiter.isActiveForCategory(Replay)) - } + val timer = mock() + rateLimiter.injectForField("timer", timer) - rateLimiter.updateRetryAfterLimits("1:replay:key", null, 1) + // When the rate limiter is closed rateLimiter.close() - // wait for 1.5s to ensure the timer has run after 1s - await.untilTrue(applied) - assertTrue(applied.get()) + // Then the timer is cancelled + verify(timer).cancel() + + // And is removed by the rateLimiter + assertNull(rateLimiter.getProperty("timer")) } } From 90fd679d2dc39074c879df2a5adf0048d1be0ac1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 10 Mar 2025 17:01:17 +0100 Subject: [PATCH 16/42] Fix properly reset application/content-provider timespans (#4244) * Fix properly reset application/content-provider timespans * Update Changelog --- CHANGELOG.md | 1 + .../android/core/performance/AppStartMetrics.java | 2 ++ .../core/performance/AppStartMetricsTest.kt | 15 +++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983bbc29685..bf7c185c408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) +- Fix properly reset application/content-provider timespans for warm app starts ([#4244](https://github.com/getsentry/sentry-java/pull/4244)) ## 7.22.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 20a85584866..6107c2e1178 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -318,6 +318,8 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved appStartSpan.start(); appStartSpan.setStartedAt(nowUptimeMs); CLASS_LOADED_UPTIME_MS = nowUptimeMs; + contentProviderOnCreates.clear(); + applicationOnCreate.reset(); } else { appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index d36ff01a5fb..855973d9418 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -154,6 +154,19 @@ class AppStartMetricsTest { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) + metrics.contentProviderOnCreateTimeSpans.add( + TimeSpan().apply { + description = "ExampleContentProvider" + setStartedAt(1) + setStoppedAt(2) + } + ) + + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(3) + setStoppedAt(4) + } + // when the looper runs Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -173,6 +186,8 @@ class AppStartMetricsTest { assertTrue(metrics.shouldSendStartMeasurements()) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + assertTrue(metrics.contentProviderOnCreateTimeSpans.isEmpty()) } @Test From 2384975ebc7bbea5450d6a505d74bf8f53a2321d Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 10 Mar 2025 16:18:39 +0000 Subject: [PATCH 17/42] release: 7.22.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7c185c408..46243ecee2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 944f3ae3a02..fe3050ebc19 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.0 +versionName=7.22.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 7c028eb882350c7750690514d3cc7e379792a972 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 13 Mar 2025 11:14:48 +0100 Subject: [PATCH 18/42] Fix AbstractMethodError when using SentryTraced for Jetpack Compose (7.x.x) (#4256) * Fix AbstractMethodError when using SentryTraced for Jetpack Compose * Override default interface impl to fix AbstractMethodError * Update Changelog * Update Changelog --- CHANGELOG.md | 6 ++++++ .../androidMain/kotlin/io/sentry/compose/SentryModifier.kt | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46243ecee2e..1a5eb4f7b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4256](https://github.com/getsentry/sentry-java/pull/4256)) + ## 7.22.1 ### Fixes diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 39ac3216610..e2b7bb07192 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -51,6 +51,12 @@ public object SentryModifier { Modifier.Node(), SemanticsModifierNode { + override val shouldClearDescendantSemantics: Boolean + get() = false + + override val shouldMergeDescendantSemantics: Boolean + get() = false + override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } From 434c8033164f832a515956f9c687c87d2338f8d6 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 13 Mar 2025 17:07:34 +0000 Subject: [PATCH 19/42] release: 7.22.2 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5eb4f7b71..a40a5e77618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.2 ### Fixes diff --git a/gradle.properties b/gradle.properties index fe3050ebc19..a45a1152629 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.1 +versionName=7.22.2 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 9c964d04b4015b065a9839ffca68a7e8e05806a0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 17 Mar 2025 13:36:23 +0100 Subject: [PATCH 20/42] Reduce excessive CPU usage when serializing breadcrumbs to disk (#4181) (#4260) * WIP * WIP * Remove redundant line * Add Tests * api dump * Formatting * REset scope cache on new init * Clean up * Comment * Changelog * Workaround https://github.com/square/tape/issues/173 * Add a comment to setBreadcrumbs * Address PR review * Update CHANGELOG.md --- CHANGELOG.md | 6 + buildSrc/src/main/java/Config.kt | 1 + .../core/AndroidOptionsInitializer.java | 14 +- .../android/core/AnrV2EventProcessor.java | 47 +- .../core/AndroidOptionsInitializerTest.kt | 7 +- .../android/core/AnrV2EventProcessorTest.kt | 16 +- .../sentry/android/core/SentryAndroidTest.kt | 56 +- .../android/replay/ReplayIntegration.kt | 8 +- .../android/replay/ReplayIntegrationTest.kt | 22 +- sentry/api/sentry.api | 50 +- sentry/build.gradle.kts | 1 + sentry/src/main/java/io/sentry/Sentry.java | 11 + .../main/java/io/sentry/SentryOptions.java | 12 + .../main/java/io/sentry/cache/CacheUtils.java | 17 +- .../sentry/cache/PersistingScopeObserver.java | 148 +++- .../sentry/cache/tape/EmptyObjectQueue.java | 52 ++ .../io/sentry/cache/tape/FileObjectQueue.java | 148 ++++ .../io/sentry/cache/tape/ObjectQueue.java | 108 +++ .../java/io/sentry/cache/tape/QueueFile.java | 817 ++++++++++++++++++ .../java/io/sentry/cache/CacheUtilsTest.kt | 10 + .../cache/PersistingScopeObserverTest.kt | 71 +- .../sentry/cache/tape/CorruptQueueFileTest.kt | 43 + .../io/sentry/cache/tape/ObjectQueueTest.kt | 252 ++++++ .../io/sentry/cache/tape/QueueFileTest.kt | 730 ++++++++++++++++ .../src/test/resources/corrupt_queue_file.txt | Bin 0 -> 4100 bytes 25 files changed, 2523 insertions(+), 124 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/QueueFile.java create mode 100644 sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt create mode 100644 sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt create mode 100644 sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt create mode 100644 sentry/src/test/resources/corrupt_queue_file.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a40a5e77618..936bd3fe290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) + ## 7.22.2 ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8f7f495e27f..26c6e9775b1 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -202,6 +202,7 @@ object Config { val msgpack = "org.msgpack:msgpack-core:0.9.8" val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$composeVersion" + val okio = "com.squareup.okio:okio:1.13.0" } object QualityPlugins { 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 80d81671ee5..5378f93110b 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 @@ -143,6 +143,11 @@ static void initializeIntegrationsAndProcessors( new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider)); } + if (options.getCacheDirPath() != null) { + options.addScopeObserver(new PersistingScopeObserver(options)); + options.addOptionsObserver(new PersistingOptionsObserver(options)); + } + options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); options.addEventProcessor( new DefaultAndroidEventProcessor(context, buildInfoProvider, options)); @@ -221,13 +226,6 @@ static void initializeIntegrationsAndProcessors( } } options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); - - if (options.getCacheDirPath() != null) { - if (options.isEnableScopePersistence()) { - options.addScopeObserver(new PersistingScopeObserver(options)); - } - options.addOptionsObserver(new PersistingOptionsObserver(options)); - } } static void installDefaultIntegrations( @@ -273,6 +271,8 @@ static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it options.addIntegration(new AppLifecycleIntegration()); + // 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)); // registerActivityLifecycleCallbacks is only available if Context is an AppContext diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 990facd6244..0f07151d2fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -33,6 +33,7 @@ import io.sentry.SentryEvent; import io.sentry.SentryExceptionFactory; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; import io.sentry.android.core.internal.util.CpuInfoUtils; @@ -83,6 +84,8 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable PersistingScopeObserver persistingScopeObserver; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, @@ -90,6 +93,7 @@ public AnrV2EventProcessor( this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; + this.persistingScopeObserver = options.findPersistingScopeObserver(); final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -188,8 +192,7 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { } private void setReplayId(final @NotNull SentryEvent event) { - @Nullable - String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); final @NotNull File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); if (!replayFolder.exists()) { @@ -224,8 +227,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } private void setTrace(final @NotNull SentryEvent event) { - final SpanContext spanContext = - PersistingScopeObserver.read(options, TRACE_FILENAME, SpanContext.class); + final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); if (event.getContexts().getTrace() == null) { if (spanContext != null && spanContext.getSpanId() != null @@ -236,8 +238,7 @@ private void setTrace(final @NotNull SentryEvent event) { } private void setLevel(final @NotNull SentryEvent event) { - final SentryLevel level = - PersistingScopeObserver.read(options, LEVEL_FILENAME, SentryLevel.class); + final SentryLevel level = readFromDisk(options, LEVEL_FILENAME, SentryLevel.class); if (event.getLevel() == null) { event.setLevel(level); } @@ -246,7 +247,7 @@ private void setLevel(final @NotNull SentryEvent event) { @SuppressWarnings("unchecked") private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Object hint) { final List fingerprint = - (List) PersistingScopeObserver.read(options, FINGERPRINT_FILENAME, List.class); + (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); if (event.getFingerprints() == null) { event.setFingerprints(fingerprint); } @@ -262,16 +263,14 @@ private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Ob } private void setTransaction(final @NotNull SentryEvent event) { - final String transaction = - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String.class); + final String transaction = readFromDisk(options, TRANSACTION_FILENAME, String.class); if (event.getTransaction() == null) { event.setTransaction(transaction); } } private void setContexts(final @NotNull SentryBaseEvent event) { - final Contexts persistedContexts = - PersistingScopeObserver.read(options, CONTEXTS_FILENAME, Contexts.class); + final Contexts persistedContexts = readFromDisk(options, CONTEXTS_FILENAME, Contexts.class); if (persistedContexts == null) { return; } @@ -291,7 +290,7 @@ private void setContexts(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setExtras(final @NotNull SentryBaseEvent event) { final Map extras = - (Map) PersistingScopeObserver.read(options, EXTRAS_FILENAME, Map.class); + (Map) readFromDisk(options, EXTRAS_FILENAME, Map.class); if (extras == null) { return; } @@ -309,14 +308,12 @@ private void setExtras(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { final List breadcrumbs = - (List) - PersistingScopeObserver.read( - options, BREADCRUMBS_FILENAME, List.class, new Breadcrumb.Deserializer()); + (List) readFromDisk(options, BREADCRUMBS_FILENAME, List.class); if (breadcrumbs == null) { return; } if (event.getBreadcrumbs() == null) { - event.setBreadcrumbs(new ArrayList<>(breadcrumbs)); + event.setBreadcrumbs(breadcrumbs); } else { event.getBreadcrumbs().addAll(breadcrumbs); } @@ -326,7 +323,7 @@ private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { private void setScopeTags(final @NotNull SentryBaseEvent event) { final Map tags = (Map) - PersistingScopeObserver.read(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); + readFromDisk(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); if (tags == null) { return; } @@ -343,19 +340,29 @@ private void setScopeTags(final @NotNull SentryBaseEvent event) { private void setUser(final @NotNull SentryBaseEvent event) { if (event.getUser() == null) { - final User user = PersistingScopeObserver.read(options, USER_FILENAME, User.class); + final User user = readFromDisk(options, USER_FILENAME, User.class); event.setUser(user); } } private void setRequest(final @NotNull SentryBaseEvent event) { if (event.getRequest() == null) { - final Request request = - PersistingScopeObserver.read(options, REQUEST_FILENAME, Request.class); + final Request request = readFromDisk(options, REQUEST_FILENAME, Request.class); event.setRequest(request); } } + private @Nullable T readFromDisk( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + if (persistingScopeObserver == null) { + return null; + } + + return persistingScopeObserver.read(options, fileName, clazz); + } + // endregion // region options persisted values diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index f234f7a6402..31d1547ca49 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -20,6 +20,7 @@ import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -55,6 +56,7 @@ class AndroidOptionsInitializerTest { configureContext: Context.() -> Unit = {}, assets: AssetManager? = null ) { + sentryOptions.executorService = ImmediateExecutorService() mockContext = if (metadata != null) { ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext), @@ -686,9 +688,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `PersistingScopeObserver is not set to options, if scope persistence is disabled`() { + fun `PersistingScopeObserver is no-op, if scope persistence is disabled`() { fixture.initSut(configureOptions = { isEnableScopePersistence = false }) - assertTrue { fixture.sentryOptions.scopeObservers.none { it is PersistingScopeObserver } } + fixture.sentryOptions.findPersistingScopeObserver()?.setTags(mapOf("key" to "value")) + assertFalse(File(AndroidOptionsInitializer.getCacheDir(fixture.context), PersistingScopeObserver.SCOPE_CACHE).exists()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 6065e81e086..6d2d005eaab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -35,6 +35,7 @@ import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.PersistingScopeObserver.USER_FILENAME +import io.sentry.cache.tape.QueueFile import io.sentry.hints.AbnormalExit import io.sentry.hints.Backfillable import io.sentry.protocol.Browser @@ -61,6 +62,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowBuild +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -98,6 +100,7 @@ class AnrV2EventProcessorTest { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" options.isSendDefaultPii = isSendDefaultPii + options.addScopeObserver(PersistingScopeObserver(options)) whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -147,7 +150,16 @@ class AnrV2EventProcessorTest { fun persistScope(filename: String, entity: T) { val dir = File(options.cacheDirPath, SCOPE_CACHE).also { it.mkdirs() } val file = File(dir, filename) - options.serializer.serialize(entity, file.writer()) + if (filename == BREADCRUMBS_FILENAME) { + val queueFile = QueueFile.Builder(file).build() + (entity as List).forEach { crumb -> + val baos = ByteArrayOutputStream() + options.serializer.serialize(crumb, baos.writer()) + queueFile.add(baos.toByteArray()) + } + } else { + options.serializer.serialize(entity, file.writer()) + } } fun persistOptions(filename: String, entity: T) { @@ -621,7 +633,7 @@ class AnrV2EventProcessorTest { val processed = processor.process(SentryEvent(), hint)!! assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) - assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + assertEquals(replayId1.toString(), fixture.options.findPersistingScopeObserver()?.read(fixture.options, REPLAY_FILENAME, String::class.java)) } private fun processEvent( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index a4608582f78..9645bb0b2d5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -11,6 +11,7 @@ import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger import io.sentry.ISentryClient @@ -36,10 +37,13 @@ import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.cache.tape.QueueFile +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils import org.awaitility.kotlin.await @@ -61,6 +65,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Files import java.util.concurrent.TimeUnit @@ -413,27 +418,31 @@ class SentryAndroidTest { assertEquals("Debug!", event.breadcrumbs!![0].message) assertEquals("staging", event.environment) assertEquals("io.sentry.sample@2.0.0", event.release) + assertEquals("afcb46b1140ade5187c4bbb5daa804df", event.contexts[Contexts.REPLAY_ID]) asserted.set(true) null } // have to do it after the cacheDir is set to options, because it adds a dsn hash after prefillOptionsCache(it.cacheDirPath!!) - prefillScopeCache(it.cacheDirPath!!) + prefillScopeCache(it, it.cacheDirPath!!) it.release = "io.sentry.sample@1.1.0+220" it.environment = "debug" - // this is necessary to delay the AnrV2Integration processing to execute the configure - // scope block below (otherwise it won't be possible as hub is no-op before .init) - it.executorService.submit { - Sentry.configureScope { scope -> - // make sure the scope values changed to test that we're still using previously - // persisted values for the old ANR events - assertEquals("TestActivity", scope.transactionName) - } - } options = it } + options.executorService.submit { + // verify we reset the persisted scope values after the init bg tasks have run to ensure + // clean state for a new process. + assertEquals( + emptyList(), + options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java) + ) + assertEquals( + SentryId.EMPTY_ID.toString(), + options.findPersistingScopeObserver()?.read(options, REPLAY_FILENAME, String::class.java) + ) + } Sentry.configureScope { it.setTransaction("TestActivity") it.addBreadcrumb(Breadcrumb.error("Error!")) @@ -447,7 +456,7 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) + options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java) ) assertEquals( "io.sentry.sample@1.1.0+220", @@ -528,19 +537,22 @@ class SentryAndroidTest { assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) } - private fun prefillScopeCache(cacheDir: String) { + private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } - File(scopeDir, BREADCRUMBS_FILENAME).writeText( - """ - [{ - "timestamp": "2009-11-16T01:08:47.000Z", - "message": "Debug!", - "type": "debug", - "level": "debug" - }] - """.trimIndent() + val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build() + val baos = ByteArrayOutputStream() + options.serializer.serialize( + Breadcrumb(DateUtils.getDateTime("2009-11-16T01:08:47.000Z")).apply { + message = "Debug!" + type = "debug" + level = DEBUG + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(scopeDir, TRANSACTION_FILENAME).writeText("\"MainActivity\"") + File(scopeDir, REPLAY_FILENAME).writeText("\"afcb46b1140ade5187c4bbb5daa804df\"") + File(options.getCacheDirPath(), "replay_afcb46b1140ade5187c4bbb5daa804df").mkdirs() } private fun prefillOptionsCache(cacheDir: String) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 655b3ca354b..8c572c003c1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -37,7 +37,6 @@ import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable @@ -412,7 +411,8 @@ public class ReplayIntegration( // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { - val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + val persistingScopeObserver = options.findPersistingScopeObserver() + val previousReplayIdString = persistingScopeObserver?.read(options, REPLAY_FILENAME, String::class.java) ?: run { cleanupReplays() return@submitSafely } @@ -425,7 +425,9 @@ public class ReplayIntegration( cleanupReplays() return@submitSafely } - val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + + @Suppress("UNCHECKED_CAST") + val breadcrumbs = persistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java) as? List val segment = CaptureStrategy.createSegment( hub = hub, options = options, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 353b11d8f66..93632d2df7d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -32,6 +32,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.tape.QueueFile import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent @@ -59,6 +60,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -456,6 +458,7 @@ class ReplayIntegrationTest { val oldReplayId = SentryId() fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) val oldReplay = File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } @@ -472,17 +475,18 @@ class ReplayIntegrationTest { it.writeText("\"$oldReplayId\"") } val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + val queueFile = QueueFile.Builder(breadcrumbsFile).build() + val baos = ByteArrayOutputStream() fixture.options.serializer.serialize( - listOf( - Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { - category = "navigation" - type = "navigation" - setData("from", "from") - setData("to", "to") - } - ), - breadcrumbsFile.writer() + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(oldReplay, ONGOING_SEGMENT).also { it.writeText( """ diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b7cb1adfc7e..d26d3474911 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2365,6 +2365,7 @@ public class io/sentry/SentryOptions { public fun addScopeObserver (Lio/sentry/IScopeObserver;)V public fun addTracingOrigin (Ljava/lang/String;)V public static fun empty ()Lio/sentry/SentryOptions; + public fun findPersistingScopeObserver ()Lio/sentry/cache/PersistingScopeObserver; public fun getBackpressureMonitor ()Lio/sentry/backpressure/IBackpressureMonitor; public fun getBeforeBreadcrumb ()Lio/sentry/SentryOptions$BeforeBreadcrumbCallback; public fun getBeforeEmitMetricCallback ()Lio/sentry/SentryOptions$BeforeEmitMetricCallback; @@ -3395,8 +3396,9 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static final field TRANSACTION_FILENAME Ljava/lang/String; public static final field USER_FILENAME Ljava/lang/String; public fun (Lio/sentry/SentryOptions;)V - public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; - public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; + public fun resetCache ()V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V public fun setExtras (Ljava/util/Map;)V @@ -3411,6 +3413,50 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;)V } +public abstract class io/sentry/cache/tape/ObjectQueue : java/io/Closeable, java/lang/Iterable { + public fun ()V + public abstract fun add (Ljava/lang/Object;)V + public fun asList ()Ljava/util/List; + public fun clear ()V + public static fun create (Lio/sentry/cache/tape/QueueFile;Lio/sentry/cache/tape/ObjectQueue$Converter;)Lio/sentry/cache/tape/ObjectQueue; + public static fun createEmpty ()Lio/sentry/cache/tape/ObjectQueue; + public abstract fun file ()Lio/sentry/cache/tape/QueueFile; + public fun isEmpty ()Z + public abstract fun peek ()Ljava/lang/Object; + public fun peek (I)Ljava/util/List; + public fun remove ()V + public abstract fun remove (I)V + public abstract fun size ()I +} + +public abstract interface class io/sentry/cache/tape/ObjectQueue$Converter { + public abstract fun from ([B)Ljava/lang/Object; + public abstract fun toStream (Ljava/lang/Object;Ljava/io/OutputStream;)V +} + +public final class io/sentry/cache/tape/QueueFile : java/io/Closeable, java/lang/Iterable { + public fun add ([B)V + public fun add ([BII)V + public fun clear ()V + public fun close ()V + public fun file ()Ljava/io/File; + public fun isAtFullCapacity ()Z + public fun isEmpty ()Z + public fun iterator ()Ljava/util/Iterator; + public fun peek ()[B + public fun remove ()V + public fun remove (I)V + public fun size ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/cache/tape/QueueFile$Builder { + public fun (Ljava/io/File;)V + public fun build ()Lio/sentry/cache/tape/QueueFile; + public fun size (I)Lio/sentry/cache/tape/QueueFile$Builder; + public fun zero (Z)Lio/sentry/cache/tape/QueueFile$Builder; +} + public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun (Ljava/util/Date;Ljava/util/List;)V public fun getDiscardedEvents ()Ljava/util/List; diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 08efc550d5a..726b6f2f2f7 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { testImplementation(Config.TestLibs.awaitility) testImplementation(Config.TestLibs.javaFaker) testImplementation(Config.TestLibs.msgpack) + testImplementation(Config.TestLibs.okio) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 4d363dc2612..49abb6b24cd 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -3,6 +3,7 @@ import io.sentry.backpressure.BackpressureMonitor; import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.config.PropertiesProviderFactory; import io.sentry.internal.debugmeta.NoOpDebugMetaLoader; import io.sentry.internal.debugmeta.ResourcesDebugMetaLoader; @@ -375,6 +376,16 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setReplayErrorSampleRate( options.getSessionReplay().getOnErrorSampleRate()); } + + // since it's a new SDK init we clean up persisted scope values before serializing + // new ones, so they are not making it to the new events if they were e.g. disabled + // (e.g. replayId) or are simply irrelevant (e.g. breadcrumbs). NOTE: this happens + // after the integrations relying on those values are done with processing them. + final @Nullable PersistingScopeObserver scopeCache = + options.findPersistingScopeObserver(); + if (scopeCache != null) { + scopeCache.resetCache(); + } }); } catch (Throwable e) { options.getLogger().log(SentryLevel.DEBUG, "Failed to notify options observers.", e); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 8e528ba5089..b238b057844 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -4,6 +4,7 @@ import io.sentry.backpressure.IBackpressureMonitor; import io.sentry.backpressure.NoOpBackpressureMonitor; import io.sentry.cache.IEnvelopeCache; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.clientreport.ClientReportRecorder; import io.sentry.clientreport.IClientReportRecorder; import io.sentry.clientreport.NoOpClientReportRecorder; @@ -1460,6 +1461,17 @@ public List getScopeObservers() { return observers; } + @ApiStatus.Internal + @Nullable + public PersistingScopeObserver findPersistingScopeObserver() { + for (final @NotNull IScopeObserver observer : observers) { + if (observer instanceof PersistingScopeObserver) { + return (PersistingScopeObserver) observer; + } + } + return null; + } + /** * Adds a SentryOptions observer * diff --git a/sentry/src/main/java/io/sentry/cache/CacheUtils.java b/sentry/src/main/java/io/sentry/cache/CacheUtils.java index 1eb5f7e19f4..eb9732a3439 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheUtils.java +++ b/sentry/src/main/java/io/sentry/cache/CacheUtils.java @@ -38,13 +38,6 @@ static void store( } final File file = new File(cacheDir, fileName); - if (file.exists()) { - options.getLogger().log(DEBUG, "Overwriting %s in scope cache", fileName); - if (!file.delete()) { - options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); - } - } - try (final OutputStream outputStream = new FileOutputStream(file); final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { options.getSerializer().serialize(entity, writer); @@ -64,11 +57,9 @@ static void delete( } final File file = new File(cacheDir, fileName); - if (file.exists()) { - options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); - if (!file.delete()) { - options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); - } + options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); + if (!file.delete()) { + options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); } } @@ -102,7 +93,7 @@ static void delete( return null; } - private static @Nullable File ensureCacheDir( + static @Nullable File ensureCacheDir( final @NotNull SentryOptions options, final @NotNull String cacheDirName) { final String cacheDir = options.getCacheDirPath(); if (cacheDir == null) { diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 908e2c66e41..c9356579c9c 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -1,18 +1,33 @@ package io.sentry.cache; import static io.sentry.SentryLevel.ERROR; +import static io.sentry.SentryLevel.INFO; +import static io.sentry.cache.CacheUtils.ensureCacheDir; import io.sentry.Breadcrumb; import io.sentry.IScope; -import io.sentry.JsonDeserializer; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanContext; +import io.sentry.cache.tape.ObjectQueue; +import io.sentry.cache.tape.QueueFile; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.LazyEvaluator; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; import java.util.Collection; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -20,6 +35,8 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { + private static final Charset UTF_8 = Charset.forName("UTF-8"); + public static final String SCOPE_CACHE = ".scope-cache"; public static final String USER_FILENAME = "user.json"; public static final String BREADCRUMBS_FILENAME = "breadcrumbs.json"; @@ -33,7 +50,60 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String TRACE_FILENAME = "trace.json"; public static final String REPLAY_FILENAME = "replay.json"; - private final @NotNull SentryOptions options; + private @NotNull SentryOptions options; + private final @NotNull LazyEvaluator> breadcrumbsQueue = + new LazyEvaluator<>( + () -> { + final File cacheDir = ensureCacheDir(options, SCOPE_CACHE); + if (cacheDir == null) { + options.getLogger().log(INFO, "Cache dir is not set, cannot store in scope cache"); + return ObjectQueue.createEmpty(); + } + + QueueFile queueFile = null; + final File file = new File(cacheDir, BREADCRUMBS_FILENAME); + try { + try { + queueFile = new QueueFile.Builder(file).size(options.getMaxBreadcrumbs()).build(); + } catch (IOException e) { + // if file is corrupted we simply delete it and try to create it again. We accept + // the trade + // off of losing breadcrumbs for ANRs that happened right before the app has + // received an + // update where the new format was introduced + file.delete(); + + queueFile = new QueueFile.Builder(file).size(options.getMaxBreadcrumbs()).build(); + } + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to create breadcrumbs queue", e); + return ObjectQueue.createEmpty(); + } + return ObjectQueue.create( + queueFile, + new ObjectQueue.Converter() { + @Override + @Nullable + public Breadcrumb from(byte[] source) { + try (final Reader reader = + new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(source), UTF_8))) { + return options.getSerializer().deserialize(reader, Breadcrumb.class); + } catch (Throwable e) { + options.getLogger().log(ERROR, e, "Error reading entity from scope cache"); + } + return null; + } + + @Override + public void toStream(Breadcrumb value, OutputStream sink) throws IOException { + try (final Writer writer = + new BufferedWriter(new OutputStreamWriter(sink, UTF_8))) { + options.getSerializer().serialize(value, writer); + } + } + }); + }); public PersistingScopeObserver(final @NotNull SentryOptions options) { this.options = options; @@ -51,9 +121,32 @@ public void setUser(final @Nullable User user) { }); } + @Override + public void addBreadcrumb(@NotNull Breadcrumb crumb) { + serializeToDisk( + () -> { + try { + breadcrumbsQueue.getValue().add(crumb); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to add breadcrumb to file queue", e); + } + }); + } + @Override public void setBreadcrumbs(@NotNull Collection breadcrumbs) { - serializeToDisk(() -> store(breadcrumbs, BREADCRUMBS_FILENAME)); + if (breadcrumbs.isEmpty()) { + // we only clear the queue if the new collection is empty (someone called clearBreadcrumbs) + // If it's not empty, we'd add breadcrumbs one-by-one in the method above + serializeToDisk( + () -> { + try { + breadcrumbsQueue.getValue().clear(); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to clear breadcrumbs from file queue", e); + } + }); + } } @Override @@ -133,9 +226,16 @@ public void setReplayId(@NotNull SentryId replayId) { @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { + if (!options.isEnableScopePersistence()) { + return; + } if (Thread.currentThread().getName().contains("SentryExecutor")) { // we're already on the sentry executor thread, so we can just execute it directly - task.run(); + try { + task.run(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task failed", e); + } return; } @@ -170,18 +270,42 @@ public static void store( CacheUtils.store(options, entity, SCOPE_CACHE, fileName); } - public static @Nullable T read( + public @Nullable T read( final @NotNull SentryOptions options, final @NotNull String fileName, final @NotNull Class clazz) { - return read(options, fileName, clazz, null); + if (fileName.equals(BREADCRUMBS_FILENAME)) { + try { + return clazz.cast(breadcrumbsQueue.getValue().asList()); + } catch (IOException e) { + options.getLogger().log(ERROR, "Unable to read serialized breadcrumbs from QueueFile"); + return null; + } + } + return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, null); } - public static @Nullable T read( - final @NotNull SentryOptions options, - final @NotNull String fileName, - final @NotNull Class clazz, - final @Nullable JsonDeserializer elementDeserializer) { - return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, elementDeserializer); + /** + * Resets the scope cache by deleting the files and/or clearing the QueueFiles. Note: this does + * I/O and should be called from a background thread. + */ + public void resetCache() { + // since it keeps a reference to the file and we cannot delete it, breadcrumbs we just clear + try { + breadcrumbsQueue.getValue().clear(); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to clear breadcrumbs from file queue", e); + } + + // the rest we can safely delete + delete(USER_FILENAME); + delete(LEVEL_FILENAME); + delete(REQUEST_FILENAME); + delete(FINGERPRINT_FILENAME); + delete(CONTEXTS_FILENAME); + delete(EXTRAS_FILENAME); + delete(TAGS_FILENAME); + delete(TRACE_FILENAME); + delete(TRANSACTION_FILENAME); } } diff --git a/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java new file mode 100644 index 00000000000..2aa41c9b791 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java @@ -0,0 +1,52 @@ +package io.sentry.cache.tape; + +import java.io.IOException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class EmptyObjectQueue extends ObjectQueue { + @Override + public @Nullable QueueFile file() { + return null; + } + + @Override + public int size() { + return 0; + } + + @Override + public void add(T entry) throws IOException {} + + @Override + public @Nullable T peek() throws IOException { + return null; + } + + @Override + public void remove(int n) throws IOException {} + + @Override + public void close() throws IOException {} + + @NotNull + @Override + public Iterator iterator() { + return new EmptyIterator<>(); + } + + private static final class EmptyIterator implements Iterator { + + @Override + public boolean hasNext() { + return false; + } + + @Override + public T next() { + throw new NoSuchElementException("No elements in EmptyIterator!"); + } + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java new file mode 100644 index 00000000000..8ed9cef56e1 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java @@ -0,0 +1,148 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Iterator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class FileObjectQueue extends ObjectQueue { + /** Backing storage implementation. */ + private final QueueFile queueFile; + /** Reusable byte output buffer. */ + private final DirectByteArrayOutputStream bytes = new DirectByteArrayOutputStream(); + + final Converter converter; + + FileObjectQueue(QueueFile queueFile, Converter converter) { + this.queueFile = queueFile; + this.converter = converter; + } + + @Override + public @NotNull QueueFile file() { + return queueFile; + } + + @Override + public int size() { + return queueFile.size(); + } + + @Override + public boolean isEmpty() { + return queueFile.isEmpty(); + } + + @Override + public void add(T entry) throws IOException { + bytes.reset(); + converter.toStream(entry, bytes); + queueFile.add(bytes.getArray(), 0, bytes.size()); + } + + @Override + public @Nullable T peek() throws IOException { + byte[] bytes = queueFile.peek(); + if (bytes == null) return null; + return converter.from(bytes); + } + + @Override + public void remove() throws IOException { + queueFile.remove(); + } + + @Override + public void remove(int n) throws IOException { + queueFile.remove(n); + } + + @Override + public void clear() throws IOException { + queueFile.clear(); + } + + @Override + public void close() throws IOException { + queueFile.close(); + } + + /** + * Returns an iterator over entries in this queue. + * + *

The iterator disallows modifications to the queue during iteration. Removing entries from + * the head of the queue is permitted during iteration using {@link Iterator#remove()}. + * + *

The iterator may throw an unchecked {@link IOException} during {@link Iterator#next()} or + * {@link Iterator#remove()}. + */ + @Override + public Iterator iterator() { + return new QueueFileIterator(queueFile.iterator()); + } + + @Override + public String toString() { + return "FileObjectQueue{" + "queueFile=" + queueFile + '}'; + } + + private final class QueueFileIterator implements Iterator { + final Iterator iterator; + + QueueFileIterator(Iterator iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + @Nullable + public T next() { + byte[] data = iterator.next(); + try { + return converter.from(data); + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } + } + + @Override + public void remove() { + iterator.remove(); + } + } + + /** Enables direct access to the internal array. Avoids unnecessary copying. */ + private static final class DirectByteArrayOutputStream extends ByteArrayOutputStream { + DirectByteArrayOutputStream() {} + + /** + * Gets a reference to the internal byte array. The {@link #size()} method indicates how many + * bytes contain actual data added since the last {@link #reset()} call. + */ + byte[] getArray() { + return buf; + } + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java new file mode 100644 index 00000000000..c92cad36218 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java @@ -0,0 +1,108 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** A queue of objects. */ +@ApiStatus.Internal +public abstract class ObjectQueue implements Iterable, Closeable { + /** A queue for objects that are atomically and durably serialized to {@code file}. */ + public static ObjectQueue create(QueueFile qf, Converter converter) { + return new FileObjectQueue<>(qf, converter); + } + + /** An empty queue for objects that is essentially a no-op. */ + public static ObjectQueue createEmpty() { + return new EmptyObjectQueue<>(); + } + + /** The underlying {@link QueueFile} backing this queue, or null if it's only in memory. */ + public abstract @Nullable QueueFile file(); + + /** Returns the number of entries in the queue. */ + public abstract int size(); + + /** Returns {@code true} if this queue contains no entries. */ + public boolean isEmpty() { + return size() == 0; + } + + /** Enqueues an entry that can be processed at any time. */ + public abstract void add(T entry) throws IOException; + + /** + * Returns the head of the queue, or {@code null} if the queue is empty. Does not modify the + * queue. + */ + public abstract @Nullable T peek() throws IOException; + + /** + * Reads up to {@code max} entries from the head of the queue without removing the entries. If the + * queue's {@link #size()} is less than {@code max} then only {@link #size()} entries are read. + */ + public List peek(int max) throws IOException { + int end = Math.min(max, size()); + List subList = new ArrayList(end); + Iterator iterator = iterator(); + for (int i = 0; i < end; i++) { + subList.add(iterator.next()); + } + return Collections.unmodifiableList(subList); + } + + /** Returns the entries in the queue as an unmodifiable {@link List}. */ + public List asList() throws IOException { + return peek(size()); + } + + /** Removes the head of the queue. */ + public void remove() throws IOException { + remove(1); + } + + /** Removes {@code n} entries from the head of the queue. */ + public abstract void remove(int n) throws IOException; + + /** Clears this queue. Also truncates the file to the initial size. */ + public void clear() throws IOException { + remove(size()); + } + + /** + * Convert a byte stream to and from a concrete type. + * + * @param Object type. + */ + public interface Converter { + /** Converts bytes to an object. */ + @Nullable + T from(byte[] source) throws IOException; + + /** Converts {@code value} to bytes written to the specified stream. */ + void toStream(T value, OutputStream sink) throws IOException; + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java b/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java new file mode 100644 index 00000000000..bc2ed568267 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java @@ -0,0 +1,817 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import static java.lang.Math.min; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.util.ConcurrentModificationException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * A reliable, efficient, file-based, FIFO queue. Additions and removals are O(1). All operations + * are atomic. Writes are synchronous; data will be written to disk before an operation returns. The + * underlying file is structured to survive process and even system crashes. If an I/O exception is + * thrown during a mutating change, the change is aborted. It is safe to continue to use a {@code + * QueueFile} instance after an exception. + * + *

Note that this implementation is not synchronized. + * + *

In a traditional queue, the remove operation returns an element. In this queue, {@link #peek} + * and {@link #remove} are used in conjunction. Use {@code peek} to retrieve the first element, and + * then {@code remove} to remove it after successful processing. If the system crashes after {@code + * peek} and during processing, the element will remain in the queue, to be processed when the + * system restarts. + * + *

NOTE: The current implementation is built for file systems that support + * atomic segment writes (like YAFFS). Most conventional file systems don't support this; if the + * power goes out while writing a segment, the segment will contain garbage and the file will be + * corrupt. We'll add journaling support so this class can be used with more file systems later. + * + *

Construct instances with {@link Builder}. + * + * @author Bob Lee (bob@squareup.com) + */ +@ApiStatus.Internal +public final class QueueFile implements Closeable, Iterable { + /** Leading bit set to 1 indicating a versioned header and the version of 1. */ + private static final int VERSIONED_HEADER = 0x80000001; + + /** Initial file size in bytes. */ + static final int INITIAL_LENGTH = 4096; // one file system block + + /** A block of nothing to write over old data. */ + private static final byte[] ZEROES = new byte[INITIAL_LENGTH]; + + /** + * The underlying file. Uses a ring buffer to store entries. Designed so that a modification isn't + * committed or visible until we write the header. The header is much smaller than a segment. So + * long as the underlying file system supports atomic segment writes, changes to the queue are + * atomic. Storing the file length ensures we can recover from a failed expansion (i.e. if setting + * the file length succeeds but the process dies before the data can be copied). + * + *

This implementation supports two versions of the on-disk format. + * + *

+   * Format:
+   *   16-32 bytes      Header
+   *   ...              Data
+   *
+   * Header (32 bytes):
+   *   1 bit            Versioned indicator [0 = legacy (see "Legacy Header"), 1 = versioned]
+   *   31 bits          Version, always 1
+   *   8 bytes          File length
+   *   4 bytes          Element count
+   *   8 bytes          Head element position
+   *   8 bytes          Tail element position
+   *
+   * Element:
+   *   4 bytes          Data length
+   *   ...              Data
+   * 
+ */ + RandomAccessFile raf; + + /** Keep file around for error reporting. */ + final File file; + + /** The header length in bytes: 16 or 32. */ + final int headerLength = 32; + + /** Cached file length. Always a power of 2. */ + long fileLength; + + /** Number of elements. */ + int elementCount; + + /** Pointer to first (or eldest) element. */ + Element first; + + /** Pointer to last (or newest) element. */ + private Element last; + + /** In-memory buffer. Big enough to hold the header. */ + private final byte[] buffer = new byte[32]; + + /** + * The number of times this file has been structurally modified — it is incremented during {@link + * #remove(int)} and {@link #add(byte[], int, int)}. Used by {@link ElementIterator} to guard + * against concurrent modification. + */ + int modCount = 0; + + /** When true, removing an element will also overwrite data with zero bytes. */ + private final boolean zero; + + /** A number of elements at which this queue will wrap around (ring buffer). */ + private final int maxElements; + + boolean closed; + + static RandomAccessFile initializeFromFile(File file) throws IOException { + if (!file.exists()) { + // Use a temp file so we don't leave a partially-initialized file. + File tempFile = new File(file.getPath() + ".tmp"); + RandomAccessFile raf = open(tempFile); + try { + raf.setLength(INITIAL_LENGTH); + raf.seek(0); + raf.writeInt(VERSIONED_HEADER); + raf.writeLong(INITIAL_LENGTH); + } finally { + raf.close(); + } + + // A rename is atomic. + if (!tempFile.renameTo(file)) { + throw new IOException("Rename failed!"); + } + } + + return open(file); + } + + /** Opens a random access file that writes synchronously. */ + private static RandomAccessFile open(File file) throws FileNotFoundException { + return new RandomAccessFile(file, "rwd"); + } + + QueueFile(File file, RandomAccessFile raf, boolean zero, int maxElements) throws IOException { + this.file = file; + this.raf = raf; + this.zero = zero; + this.maxElements = maxElements; + + readInitialData(); + } + + private void readInitialData() throws IOException { + raf.seek(0); + raf.readFully(buffer); + + long firstOffset; + long lastOffset; + + fileLength = readLong(buffer, 4); + elementCount = readInt(buffer, 12); + firstOffset = readLong(buffer, 16); + lastOffset = readLong(buffer, 24); + + if (fileLength > raf.length()) { + throw new IOException( + "File is truncated. Expected length: " + fileLength + ", Actual length: " + raf.length()); + } else if (fileLength <= headerLength) { + throw new IOException( + "File is corrupt; length stored in header (" + fileLength + ") is invalid."); + } + + first = readElement(firstOffset); + last = readElement(lastOffset); + } + + private void resetFile() throws IOException { + raf.close(); + file.delete(); + raf = initializeFromFile(file); + readInitialData(); + } + + /** + * Stores an {@code int} in the {@code byte[]}. The behavior is equivalent to calling {@link + * RandomAccessFile#writeInt}. + */ + private static void writeInt(byte[] buffer, int offset, int value) { + buffer[offset] = (byte) (value >> 24); + buffer[offset + 1] = (byte) (value >> 16); + buffer[offset + 2] = (byte) (value >> 8); + buffer[offset + 3] = (byte) value; + } + + /** Reads an {@code int} from the {@code byte[]}. */ + private static int readInt(byte[] buffer, int offset) { + return ((buffer[offset] & 0xff) << 24) + + ((buffer[offset + 1] & 0xff) << 16) + + ((buffer[offset + 2] & 0xff) << 8) + + (buffer[offset + 3] & 0xff); + } + + /** + * Stores an {@code long} in the {@code byte[]}. The behavior is equivalent to calling {@link + * RandomAccessFile#writeLong}. + */ + private static void writeLong(byte[] buffer, int offset, long value) { + buffer[offset] = (byte) (value >> 56); + buffer[offset + 1] = (byte) (value >> 48); + buffer[offset + 2] = (byte) (value >> 40); + buffer[offset + 3] = (byte) (value >> 32); + buffer[offset + 4] = (byte) (value >> 24); + buffer[offset + 5] = (byte) (value >> 16); + buffer[offset + 6] = (byte) (value >> 8); + buffer[offset + 7] = (byte) value; + } + + /** Reads an {@code long} from the {@code byte[]}. */ + private static long readLong(byte[] buffer, int offset) { + return ((buffer[offset] & 0xffL) << 56) + + ((buffer[offset + 1] & 0xffL) << 48) + + ((buffer[offset + 2] & 0xffL) << 40) + + ((buffer[offset + 3] & 0xffL) << 32) + + ((buffer[offset + 4] & 0xffL) << 24) + + ((buffer[offset + 5] & 0xffL) << 16) + + ((buffer[offset + 6] & 0xffL) << 8) + + (buffer[offset + 7] & 0xffL); + } + + /** + * Writes header atomically. The arguments contain the updated values. The class member fields + * should not have changed yet. This only updates the state in the file. It's up to the caller to + * update the class member variables *after* this call succeeds. Assumes segment writes are atomic + * in the underlying file system. + */ + private void writeHeader(long fileLength, int elementCount, long firstPosition, long lastPosition) + throws IOException { + raf.seek(0); + + writeInt(buffer, 0, VERSIONED_HEADER); + writeLong(buffer, 4, fileLength); + writeInt(buffer, 12, elementCount); + writeLong(buffer, 16, firstPosition); + writeLong(buffer, 24, lastPosition); + raf.write(buffer, 0, 32); + } + + Element readElement(long position) throws IOException { + if (position == 0) return Element.NULL; + boolean success = ringRead(position, buffer, 0, Element.HEADER_LENGTH); + if (!success) { + return Element.NULL; + } + int length = readInt(buffer, 0); + return new Element(position, length); + } + + /** Wraps the position if it exceeds the end of the file. */ + long wrapPosition(long position) { + return position < fileLength ? position : headerLength + position - fileLength; + } + + /** + * Writes count bytes from buffer to position in file. Automatically wraps write if position is + * past the end of the file or if buffer overlaps it. + * + * @param position in file to write to + * @param buffer to write from + * @param count # of bytes to write + */ + private void ringWrite(long position, byte[] buffer, int offset, int count) throws IOException { + position = wrapPosition(position); + if (position + count <= fileLength) { + raf.seek(position); + raf.write(buffer, offset, count); + } else { + // The write overlaps the EOF. + // # of bytes to write before the EOF. Guaranteed to be less than Integer.MAX_VALUE. + int beforeEof = (int) (fileLength - position); + raf.seek(position); + raf.write(buffer, offset, beforeEof); + raf.seek(headerLength); + raf.write(buffer, offset + beforeEof, count - beforeEof); + } + } + + private void ringErase(long position, long length) throws IOException { + while (length > 0) { + int chunk = (int) min(length, ZEROES.length); + ringWrite(position, ZEROES, 0, chunk); + length -= chunk; + position += chunk; + } + } + + /** + * Reads count bytes into buffer from file. Wraps if necessary. + * + * @param position in file to read from + * @param buffer to read into + * @param count # of bytes to read + * @return true if the read was successful, false if the file is corrupt + */ + boolean ringRead(long position, byte[] buffer, int offset, int count) throws IOException { + try { + position = wrapPosition(position); + if (position + count <= fileLength) { + raf.seek(position); + raf.readFully(buffer, offset, count); + } else { + // The read overlaps the EOF. + // # of bytes to read before the EOF. Guaranteed to be less than Integer.MAX_VALUE. + int beforeEof = (int) (fileLength - position); + raf.seek(position); + raf.readFully(buffer, offset, beforeEof); + raf.seek(headerLength); + raf.readFully(buffer, offset + beforeEof, count - beforeEof); + } + return true; + } catch (EOFException e) { + // since EOFException inherits from IOException, we need to catch it explicitly + // and reset the file + resetFile(); + } catch (IOException e) { + throw e; + } catch (Throwable e) { + // most likely the file is corrupt, so we delete it and recreate, accepting data loss + resetFile(); + } + return false; + } + + /** + * Adds an element to the end of the queue. + * + * @param data to copy bytes from + */ + public void add(byte[] data) throws IOException { + add(data, 0, data.length); + } + + /** + * Adds an element to the end of the queue. + * + * @param data to copy bytes from + * @param offset to start from in buffer + * @param count number of bytes to copy + * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code count < 0}, or if {@code + * offset + count} is bigger than the length of {@code buffer}. + */ + public void add(byte[] data, int offset, int count) throws IOException { + if (data == null) { + throw new NullPointerException("data == null"); + } + if ((offset | count) < 0 || count > data.length - offset) { + throw new IndexOutOfBoundsException(); + } + if (closed) throw new IllegalStateException("closed"); + + // If the queue is at full capacity, remove the oldest element first. + if (isAtFullCapacity()) { + remove(); + } + + expandIfNecessary(count); + + // Insert a new element after the current last element. + boolean wasEmpty = isEmpty(); + long position = + wasEmpty ? headerLength : wrapPosition(last.position + Element.HEADER_LENGTH + last.length); + Element newLast = new Element(position, count); + + // Write length. + writeInt(buffer, 0, count); + ringWrite(newLast.position, buffer, 0, Element.HEADER_LENGTH); + + // Write data. + ringWrite(newLast.position + Element.HEADER_LENGTH, data, offset, count); + + // Commit the addition. If wasEmpty, first == last. + long firstPosition = wasEmpty ? newLast.position : first.position; + writeHeader(fileLength, elementCount + 1, firstPosition, newLast.position); + last = newLast; + elementCount++; + modCount++; + if (wasEmpty) first = last; // first element + } + + private long usedBytes() { + if (elementCount == 0) return headerLength; + + if (last.position >= first.position) { + // Contiguous queue. + return (last.position - first.position) // all but last entry + + Element.HEADER_LENGTH + + last.length // last entry + + headerLength; + } else { + // tail < head. The queue wraps. + return last.position // buffer front + header + + Element.HEADER_LENGTH + + last.length // last entry + + fileLength + - first.position; // buffer end + } + } + + private long remainingBytes() { + return fileLength - usedBytes(); + } + + /** Returns true if this queue contains no entries. */ + public boolean isEmpty() { + return elementCount == 0; + } + + /** + * If necessary, expands the file to accommodate an additional element of the given length. + * + * @param dataLength length of data being added + */ + private void expandIfNecessary(long dataLength) throws IOException { + long elementLength = Element.HEADER_LENGTH + dataLength; + long remainingBytes = remainingBytes(); + if (remainingBytes >= elementLength) return; + + // Expand. + long previousLength = fileLength; + long newLength; + // Double the length until we can fit the new data. + do { + remainingBytes += previousLength; + newLength = previousLength << 1; + previousLength = newLength; + } while (remainingBytes < elementLength); + + setLength(newLength); + + // Calculate the position of the tail end of the data in the ring buffer + long endOfLastElement = wrapPosition(last.position + Element.HEADER_LENGTH + last.length); + long count = 0; + // If the buffer is split, we need to make it contiguous + if (endOfLastElement <= first.position) { + FileChannel channel = raf.getChannel(); + channel.position(fileLength); // destination position + count = endOfLastElement - headerLength; + if (channel.transferTo(headerLength, count, channel) != count) { + throw new AssertionError("Copied insufficient number of bytes!"); + } + } + + // Commit the expansion. + if (last.position < first.position) { + long newLastPosition = fileLength + last.position - headerLength; + writeHeader(newLength, elementCount, first.position, newLastPosition); + last = new Element(newLastPosition, last.length); + } else { + writeHeader(newLength, elementCount, first.position, last.position); + } + + fileLength = newLength; + + if (zero) { + ringErase(headerLength, count); + } + } + + /** Sets the length of the file. */ + private void setLength(long newLength) throws IOException { + // Set new file length (considered metadata) and sync it to storage. + raf.setLength(newLength); + raf.getChannel().force(true); + } + + /** Reads the eldest element. Returns null if the queue is empty. */ + public @Nullable byte[] peek() throws IOException { + if (closed) throw new IllegalStateException("closed"); + if (isEmpty()) return null; + int length = first.length; + byte[] data = new byte[length]; + boolean success = ringRead(first.position + Element.HEADER_LENGTH, data, 0, length); + return success ? data : null; + } + + /** + * Returns an iterator over elements in this QueueFile. + * + *

The iterator disallows modifications to be made to the QueueFile during iteration. Removing + * elements from the head of the QueueFile is permitted during iteration using {@link + * Iterator#remove()}. + * + *

The iterator may throw an unchecked {@link IOException} during {@link Iterator#next()} or + * {@link Iterator#remove()}. + */ + @Override + public Iterator iterator() { + return new ElementIterator(); + } + + private final class ElementIterator implements Iterator { + /** Index of element to be returned by subsequent call to next. */ + int nextElementIndex = 0; + + /** Position of element to be returned by subsequent call to next. */ + private long nextElementPosition = first.position; + + /** + * The {@link #modCount} value that the iterator believes that the backing QueueFile should + * have. If this expectation is violated, the iterator has detected concurrent modification. + */ + int expectedModCount = modCount; + + ElementIterator() {} + + private void checkForComodification() { + if (modCount != expectedModCount) throw new ConcurrentModificationException(); + } + + @Override + public boolean hasNext() { + if (closed) throw new IllegalStateException("closed"); + checkForComodification(); + return nextElementIndex != elementCount; + } + + @Override + public byte[] next() { + if (closed) throw new IllegalStateException("closed"); + checkForComodification(); + if (isEmpty()) throw new NoSuchElementException(); + if (nextElementIndex >= elementCount) throw new NoSuchElementException(); + + try { + // Read the current element. + Element current = readElement(nextElementPosition); + byte[] buffer = new byte[current.length]; + nextElementPosition = wrapPosition(current.position + Element.HEADER_LENGTH); + boolean success = ringRead(nextElementPosition, buffer, 0, current.length); + if (!success) { + // make it run out of bounds immediately + nextElementIndex = elementCount; + return ZEROES; + } + + // Update the pointer to the next element. + nextElementPosition = + wrapPosition(current.position + Element.HEADER_LENGTH + current.length); + nextElementIndex++; + + // Return the read element. + return buffer; + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } catch (OutOfMemoryError e) { + // most likely the file is corrupted, so we delete it and recreate, accepting data loss + try { + resetFile(); + // make it run out of bounds immediately + nextElementIndex = elementCount; + } catch (IOException ex) { + throw QueueFile.getSneakyThrowable(ex); + } + return ZEROES; + } + } + + @Override + public void remove() { + checkForComodification(); + + if (isEmpty()) throw new NoSuchElementException(); + if (nextElementIndex != 1) { + throw new UnsupportedOperationException("Removal is only permitted from the head."); + } + + try { + QueueFile.this.remove(); + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } + + expectedModCount = modCount; + nextElementIndex--; + } + } + + /** Returns the number of elements in this queue. */ + public int size() { + return elementCount; + } + + /** + * Removes the eldest element. + * + * @throws NoSuchElementException if the queue is empty + */ + public void remove() throws IOException { + remove(1); + } + + /** + * Removes the eldest {@code n} elements. + * + * @throws NoSuchElementException if the queue is empty + */ + public void remove(int n) throws IOException { + if (n < 0) { + throw new IllegalArgumentException("Cannot remove negative (" + n + ") number of elements."); + } + if (n == 0) { + return; + } + if (n == elementCount) { + clear(); + return; + } + if (isEmpty()) { + throw new NoSuchElementException(); + } + if (n > elementCount) { + throw new IllegalArgumentException( + "Cannot remove more elements (" + n + ") than present in queue (" + elementCount + ")."); + } + + long eraseStartPosition = first.position; + long eraseTotalLength = 0; + + // Read the position and length of the new first element. + long newFirstPosition = first.position; + int newFirstLength = first.length; + for (int i = 0; i < n; i++) { + eraseTotalLength += Element.HEADER_LENGTH + newFirstLength; + newFirstPosition = wrapPosition(newFirstPosition + Element.HEADER_LENGTH + newFirstLength); + boolean success = ringRead(newFirstPosition, buffer, 0, Element.HEADER_LENGTH); + if (!success) { + return; + } + newFirstLength = readInt(buffer, 0); + } + + // Commit the header. + writeHeader(fileLength, elementCount - n, newFirstPosition, last.position); + elementCount -= n; + modCount++; + first = new Element(newFirstPosition, newFirstLength); + + if (zero) { + ringErase(eraseStartPosition, eraseTotalLength); + } + } + + /** Clears this queue. Truncates the file to the initial size. */ + public void clear() throws IOException { + if (closed) throw new IllegalStateException("closed"); + + // Commit the header. + writeHeader(INITIAL_LENGTH, 0, 0, 0); + + if (zero) { + // Zero out data. + raf.seek(headerLength); + raf.write(ZEROES, 0, INITIAL_LENGTH - headerLength); + } + + elementCount = 0; + first = Element.NULL; + last = Element.NULL; + if (fileLength > INITIAL_LENGTH) setLength(INITIAL_LENGTH); + fileLength = INITIAL_LENGTH; + modCount++; + } + + /** + * Returns {@code true} if the capacity limit of this queue has been reached, i.e. the number of + * elements stored in the queue equals its maximum size. + * + * @return {@code true} if the capacity limit has been reached, {@code false} otherwise + */ + public boolean isAtFullCapacity() { + if (maxElements == -1) { + // unspecified + return false; + } + return size() == maxElements; + } + + /** The underlying {@link File} backing this queue. */ + public File file() { + return file; + } + + @Override + public void close() throws IOException { + closed = true; + raf.close(); + } + + @Override + public String toString() { + return "QueueFile{" + + "file=" + + file + + ", zero=" + + zero + + ", length=" + + fileLength + + ", size=" + + elementCount + + ", first=" + + first + + ", last=" + + last + + '}'; + } + + /** A pointer to an element. */ + static final class Element { + static final Element NULL = new Element(0, 0); + + /** Length of element header in bytes. */ + static final int HEADER_LENGTH = 4; + + /** Position in file. */ + final long position; + + /** The length of the data. */ + final int length; + + /** + * Constructs a new element. + * + * @param position within file + * @param length of data + */ + Element(long position, int length) { + this.position = position; + this.length = length; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "[position=" + position + ", length=" + length + "]"; + } + } + + /** Fluent API for creating {@link QueueFile} instances. */ + public static final class Builder { + final File file; + boolean zero = true; + int size = -1; + + /** Start constructing a new queue backed by the given file. */ + public Builder(File file) { + if (file == null) { + throw new NullPointerException("file == null"); + } + this.file = file; + } + + /** When true, removing an element will also overwrite data with zero bytes. */ + public Builder zero(boolean zero) { + this.zero = zero; + return this; + } + + /** The maximum number of elements this queue can hold before wrapping around. */ + public Builder size(int size) { + this.size = size; + return this; + } + + /** + * Constructs a new queue backed by the given builder. Only one instance should access a given + * file at a time. + */ + public QueueFile build() throws IOException { + RandomAccessFile raf = initializeFromFile(file); + QueueFile qf = null; + try { + qf = new QueueFile(file, raf, zero, size); + return qf; + } finally { + if (qf == null) { + raf.close(); + } + } + } + } + + /** + * Use this to throw checked exceptions from iterator methods that do not declare that they throw + * checked exceptions. + */ + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + static T getSneakyThrowable(Throwable t) throws T { + throw (T) t; + } +} diff --git a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt index ba42810cd95..daf60e679d5 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt @@ -34,6 +34,16 @@ internal class CacheUtilsTest { ) assertEquals("\"Hallo!\"", file.readText()) + + // test overwrite + CacheUtils.store( + SentryOptions().apply { cacheDirPath = cacheDir }, + "Hallo 2!", + "stuff", + "test.json" + ) + + assertEquals("\"Hallo 2!\"", file.readText()) } @Test diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index e1927438e59..631e18cf1d5 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -2,7 +2,6 @@ package io.sentry.cache import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.JsonDeserializer import io.sentry.Scope import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -56,13 +55,12 @@ class DeletedEntityProvider(private val provider: (Scope) -> T?) { } @RunWith(Parameterized::class) -class PersistingScopeObserverTest( +class PersistingScopeObserverTest( private val entity: T, private val store: StoreScopeValue, private val filename: String, private val delete: DeleteScopeValue, - private val deletedEntity: DeletedEntityProvider, - private val elementDeserializer: JsonDeserializer? + private val deletedEntity: DeletedEntityProvider ) { @get:Rule @@ -89,19 +87,19 @@ class PersistingScopeObserverTest( val sut = fixture.getSut(tmpDir) store(entity, sut, fixture.scope) - val persisted = read() + val persisted = sut.read() assertEquals(entity, persisted) delete(sut, fixture.scope) - val persistedAfterDeletion = read() + val persistedAfterDeletion = sut.read() assertEquals(deletedEntity(fixture.scope), persistedAfterDeletion) } - private fun read(): T? = PersistingScopeObserver.read( + private fun PersistingScopeObserver.read(): Any? = read( fixture.options, filename, - entity!!::class.java, - elementDeserializer + // need to cast breadcrumbs to a regular List, not kotlin lists + if (entity!!::class.java.name.contains("List")) List::class.java else entity!!::class.java ) companion object { @@ -115,8 +113,7 @@ class PersistingScopeObserverTest( StoreScopeValue { user, _ -> setUser(user) }, USER_FILENAME, DeleteScopeValue { setUser(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun breadcrumbs(): Array = arrayOf( @@ -124,11 +121,29 @@ class PersistingScopeObserverTest( Breadcrumb.navigation("one", "two"), Breadcrumb.userInteraction("click", "viewId", "viewClass") ), - StoreScopeValue> { breadcrumbs, _ -> setBreadcrumbs(breadcrumbs) }, + StoreScopeValue> { breadcrumbs, _ -> + breadcrumbs.forEach { addBreadcrumb(it) } + }, + BREADCRUMBS_FILENAME, + DeleteScopeValue { setBreadcrumbs(emptyList()) }, + DeletedEntityProvider { emptyList() } + ) + + private fun legacyBreadcrumbs(): Array = arrayOf( + emptyList(), + StoreScopeValue> { _, scope -> + PersistingScopeObserver.store( + scope.options, + listOf( + Breadcrumb.navigation("one", "two"), + Breadcrumb.userInteraction("click", "viewId", "viewClass") + ), + BREADCRUMBS_FILENAME + ) + }, BREADCRUMBS_FILENAME, DeleteScopeValue { setBreadcrumbs(emptyList()) }, - DeletedEntityProvider { emptyList() }, - Breadcrumb.Deserializer() + DeletedEntityProvider { emptyList() } ) private fun tags(): Array = arrayOf( @@ -139,8 +154,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { tags, _ -> setTags(tags) }, TAGS_FILENAME, DeleteScopeValue { setTags(emptyMap()) }, - DeletedEntityProvider { emptyMap() }, - null + DeletedEntityProvider { emptyMap() } ) private fun extras(): Array = arrayOf( @@ -152,8 +166,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { extras, _ -> setExtras(extras) }, EXTRAS_FILENAME, DeleteScopeValue { setExtras(emptyMap()) }, - DeletedEntityProvider { emptyMap() }, - null + DeletedEntityProvider { emptyMap() } ) private fun request(): Array = arrayOf( @@ -168,8 +181,7 @@ class PersistingScopeObserverTest( StoreScopeValue { request, _ -> setRequest(request) }, REQUEST_FILENAME, DeleteScopeValue { setRequest(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun fingerprint(): Array = arrayOf( @@ -177,8 +189,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { fingerprint, _ -> setFingerprint(fingerprint) }, FINGERPRINT_FILENAME, DeleteScopeValue { setFingerprint(emptyList()) }, - DeletedEntityProvider { emptyList() }, - null + DeletedEntityProvider { emptyList() } ) private fun level(): Array = arrayOf( @@ -186,8 +197,7 @@ class PersistingScopeObserverTest( StoreScopeValue { level, _ -> setLevel(level) }, LEVEL_FILENAME, DeleteScopeValue { setLevel(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun transaction(): Array = arrayOf( @@ -195,8 +205,7 @@ class PersistingScopeObserverTest( StoreScopeValue { transaction, _ -> setTransaction(transaction) }, TRANSACTION_FILENAME, DeleteScopeValue { setTransaction(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun trace(): Array = arrayOf( @@ -204,8 +213,7 @@ class PersistingScopeObserverTest( StoreScopeValue { trace, scope -> setTrace(trace, scope) }, TRACE_FILENAME, DeleteScopeValue { scope -> setTrace(null, scope) }, - DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() }, - null + DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() } ) private fun contexts(): Array = arrayOf( @@ -269,8 +277,7 @@ class PersistingScopeObserverTest( StoreScopeValue { contexts, _ -> setContexts(contexts) }, CONTEXTS_FILENAME, DeleteScopeValue { setContexts(Contexts()) }, - DeletedEntityProvider { Contexts() }, - null + DeletedEntityProvider { Contexts() } ) private fun replayId(): Array = arrayOf( @@ -278,8 +285,7 @@ class PersistingScopeObserverTest( StoreScopeValue { replayId, _ -> setReplayId(SentryId(replayId)) }, REPLAY_FILENAME, DeleteScopeValue { setReplayId(SentryId.EMPTY_ID) }, - DeletedEntityProvider { SentryId.EMPTY_ID.toString() }, - null + DeletedEntityProvider { SentryId.EMPTY_ID.toString() } ) @JvmStatic @@ -288,6 +294,7 @@ class PersistingScopeObserverTest( return listOf( user(), breadcrumbs(), + legacyBreadcrumbs(), tags(), extras(), request(), diff --git a/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt new file mode 100644 index 00000000000..1e5e0b03a0b --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt @@ -0,0 +1,43 @@ +package io.sentry.cache.tape + +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.test.assertEquals + +class CorruptQueueFileTest { + + @get:Rule + val folder = TemporaryFolder() + private lateinit var file: File + + @Before + fun setUp() { + val parent = folder.root + file = File(parent, "queue-file") + } + + @Test + fun `does not fail to operate with a corrupt file`() { + val testFile = this::class.java.classLoader.getResource("corrupt_queue_file.txt")!! + Files.copy(Paths.get(testFile.toURI()), file.outputStream()) + + val queueFile = QueueFile.Builder(file).zero(true).build() + val iterator = queueFile.iterator() + while (iterator.hasNext()) { + iterator.next() + } + + queueFile.add("test".toByteArray()) + assertEquals(1, queueFile.size()) + + queueFile.peek() + + queueFile.remove() + assertEquals(0, queueFile.size()) + } +} diff --git a/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt b/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt new file mode 100644 index 00000000000..628db5d57bc --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt @@ -0,0 +1,252 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/test/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape + +import io.sentry.cache.tape.ObjectQueue.Converter +import io.sentry.cache.tape.QueueFile.Builder +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.OutputStream +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class ObjectQueueTest { + enum class QueueFactory { + FILE { + override fun create(queueFile: QueueFile, converter: Converter): ObjectQueue { + return ObjectQueue.create(queueFile, converter) + } + }; + + abstract fun create(queueFile: QueueFile, converter: Converter): ObjectQueue + } + + @get:Rule + val folder = TemporaryFolder() + private lateinit var queue: ObjectQueue + + @Before + fun setUp() { + val parent = folder.root + val file = File(parent, "object-queue") + val queueFile = Builder(file).build() + queue = QueueFactory.FILE.create(queueFile, StringConverter()) + + queue.add("one") + queue.add("two") + queue.add("three") + } + + @Test + fun size() { + assertEquals(queue.size(), 3) + } + + @Test + fun peek() { + assertEquals(queue.peek(), "one") + } + + @Test + fun peekMultiple() { + assertEquals(queue.peek(2), listOf("one", "two")) + } + + @Test + fun peekMaxCanExceedQueueDepth() { + assertEquals(queue.peek(6), listOf("one", "two", "three")) + } + + @Test + fun asList() { + assertEquals(queue.asList(), listOf("one", "two", "three")) + } + + @Test + fun remove() { + queue.remove() + + assertEquals(queue.asList(), listOf("two", "three")) + } + + @Test + fun removeMultiple() { + queue.remove(2) + + assertEquals(queue.asList(), listOf("three")) + } + + @Test + fun clear() { + queue.clear() + + assertEquals(queue.size(), 0) + } + + @Test + fun isEmpty() { + assertFalse(queue.isEmpty) + + queue.clear() + + assertTrue(queue.isEmpty) + } + + @Test + fun testIterator() { + val saw: MutableList = ArrayList() + for (pojo in queue) { + saw.add(pojo) + } + assertEquals(saw, listOf("one", "two", "three")) + } + + @Test + fun testIteratorNextThrowsWhenEmpty() { + queue.clear() + val iterator: Iterator = queue.iterator() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorNextThrowsWhenExhausted() { + val iterator: Iterator = queue.iterator() + iterator.next() + iterator.next() + iterator.next() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorRemove() { + val iterator = queue.iterator() + + iterator.next() + iterator.remove() + assertEquals(queue.asList(), listOf("two", "three")) + + iterator.next() + iterator.remove() + assertEquals(queue.asList(), listOf("three")) + } + + @Test + fun testIteratorRemoveDisallowsConcurrentModification() { + val iterator = queue.iterator() + iterator.next() + queue.remove() + + try { + iterator.remove() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorHasNextDisallowsConcurrentModification() { + val iterator: Iterator = queue.iterator() + iterator.next() + queue.remove() + + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorDisallowsConcurrentModificationWithClear() { + val iterator: Iterator = queue.iterator() + iterator.next() + queue.clear() + + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorOnlyRemovesFromHead() { + val iterator = queue.iterator() + iterator.next() + iterator.next() + + try { + iterator.remove() + fail() + } catch (ex: UnsupportedOperationException) { + assertEquals(ex.message, "Removal is only permitted from the head.") + } + } + + @Test + fun iteratorThrowsIOException() { + val parent = folder.root + val file = File(parent, "object-queue") + val queueFile = Builder(file).build() + val queue = ObjectQueue.create( + queueFile, + object : Converter { + override fun from(bytes: ByteArray): String { + throw IOException() + } + + override fun toStream(o: Any, bytes: OutputStream) { + } + } + ) + queue.add(Any()) + val iterator = queue.iterator() + try { + iterator.next() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + } + + internal class StringConverter : Converter { + override fun from(bytes: ByteArray): String { + return String(bytes, charset("UTF-8")) + } + + override fun toStream(s: String, os: OutputStream) { + os.write(s.toByteArray(charset("UTF-8"))) + } + } +} diff --git a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt new file mode 100644 index 00000000000..8ece592c684 --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt @@ -0,0 +1,730 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/test/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape + +import io.sentry.cache.tape.QueueFile.Builder +import io.sentry.cache.tape.QueueFile.Element +import okio.BufferedSource +import okio.Okio +import org.junit.Assert +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.util.ArrayDeque +import java.util.Queue +import java.util.logging.Logger +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Tests for QueueFile. + * + * @author Bob Lee (bob@squareup.com) + */ +class QueueFileTest { + private val headerLength = 32 + + @get:Rule + val folder = TemporaryFolder() + private lateinit var file: File + + private fun newQueueFile(raf: RandomAccessFile): QueueFile { + return QueueFile(this.file, raf, true, -1) + } + + private fun newQueueFile(zero: Boolean = true, size: Int = -1): QueueFile { + return Builder(file).zero(zero).size(size).build() + } + + @Before + fun setUp() { + val parent = folder.root + file = File(parent, "queue-file") + } + + @Test + fun testAddOneElement() { + // This test ensures that we update 'first' correctly. + var queue = newQueueFile() + val expected = values[253] + queue.add(expected) + assertArrayEquals(queue.peek(), expected) + queue.close() + queue = newQueueFile() + assertArrayEquals(queue.peek(), expected) + } + + @Test + fun testClearErases() { + val queue = newQueueFile() + val expected = values[253] + queue.add(expected) + + // Confirm that the data was in the file before we cleared. + val data = ByteArray(expected!!.size) + queue.raf.seek(headerLength.toLong() + Element.HEADER_LENGTH) + queue.raf.readFully(data, 0, expected.size) + assertArrayEquals(data, expected) + + queue.clear() + + // Should have been erased. + queue.raf.seek(headerLength.toLong() + Element.HEADER_LENGTH) + queue.raf.readFully(data, 0, expected.size) + assertArrayEquals(data, ByteArray(expected.size)) + } + + @Test + fun testClearDoesNotCorrupt() { + var queue = newQueueFile() + val stuff = values[253] + queue.add(stuff) + queue.clear() + + queue = newQueueFile() + assertTrue(queue.isEmpty) + assertNull(queue.peek()) + + queue.add(values[25]) + assertArrayEquals(queue.peek(), values[25]) + } + + @Test + fun removeErasesEagerly() { + val queue = newQueueFile() + + val firstStuff = values[127] + queue.add(firstStuff) + + val secondStuff = values[253] + queue.add(secondStuff) + + // Confirm that first stuff was in the file before we remove. + val data = ByteArray(firstStuff!!.size) + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, firstStuff.size) + assertArrayEquals(data, firstStuff) + + queue.remove() + + // Next record is intact + assertArrayEquals(queue.peek(), secondStuff) + + // First should have been erased. + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, firstStuff.size) + assertArrayEquals(data, ByteArray(firstStuff.size)) + } + + @Test + fun testZeroSizeInHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail("Should have thrown about bad header length") + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (0) is invalid.") + } + } + + @Test + fun testSizeLessThanHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.writeInt(-0x7fffffff) + emptyFile.writeLong((headerLength - 1).toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail() + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (31) is invalid.") + } + } + + @Test + fun testNegativeSizeInHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.seek(0) + emptyFile.writeInt(-2147483648) + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail("Should have thrown about bad header length") + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (0) is invalid.") + } + } + + @Test + fun removeMultipleDoesNotCorrupt() { + var queue = newQueueFile() + for (i in 0..9) { + queue.add(values[i]) + } + + queue.remove(1) + assertEquals(queue.size(), 9) + assertArrayEquals(queue.peek(), values[1]) + + queue.remove(3) + queue = newQueueFile() + assertEquals(queue.size(), 6) + assertArrayEquals(queue.peek(), values[4]) + + queue.remove(6) + assertTrue(queue.isEmpty) + assertNull(queue.peek()) + } + + @Test + fun removeDoesNotCorrupt() { + var queue = newQueueFile() + + queue.add(values[127]) + val secondStuff = values[253] + queue.add(secondStuff) + queue.remove() + + queue = newQueueFile() + assertArrayEquals(queue.peek(), secondStuff) + } + + @Test + fun removeFromEmptyFileThrows() { + val queue = newQueueFile() + + try { + queue.remove() + fail("Should have thrown about removing from empty file.") + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun removeZeroFromEmptyFileDoesNothing() { + val queue = newQueueFile() + queue.remove(0) + assertTrue(queue.isEmpty) + } + + @Test + fun removeNegativeNumberOfElementsThrows() { + val queue = newQueueFile() + queue.add(values[127]) + + try { + queue.remove(-1) + fail("Should have thrown about removing negative number of elements.") + } catch (ex: IllegalArgumentException) { + assertEquals(ex.message, "Cannot remove negative (-1) number of elements.") + } + } + + @Test + fun removeZeroElementsDoesNothing() { + val queue = newQueueFile() + queue.add(values[127]) + + queue.remove(0) + assertEquals(queue.size(), 1) + } + + @Test + fun removeBeyondQueueSizeElementsThrows() { + val queue = newQueueFile() + queue.add(values[127]) + + try { + queue.remove(10) + fail("Should have thrown about removing too many elements.") + } catch (ex: IllegalArgumentException) { + assertEquals(ex.message, "Cannot remove more elements (10) than present in queue (1).") + } + } + + @Test + fun removingBigDamnBlocksErasesEffectively() { + val bigBoy = ByteArray(7000) + var i = 0 + while (i < 7000) { + System.arraycopy(values[100], 0, bigBoy, i, values[100]!!.size) + i += 100 + } + + val queue = newQueueFile() + + queue.add(bigBoy) + val secondStuff = values[123] + queue.add(secondStuff) + + // Confirm that bigBoy was in the file before we remove. + val data = ByteArray(bigBoy.size) + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, bigBoy.size) + assertArrayEquals(data, bigBoy) + + queue.remove() + + // Next record is intact + assertArrayEquals(queue.peek(), secondStuff) + + // First should have been erased. + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, bigBoy.size) + assertArrayEquals(data, ByteArray(bigBoy.size)) + } + + @Test + fun testAddAndRemoveElements() { + val start = System.nanoTime() + + val expected: Queue = ArrayDeque() + + for (round in 0..4) { + val queue = newQueueFile() + for (i in 0 until N) { + queue.add(values[i]) + expected.add(values[i]) + } + + // Leave N elements in round N, 15 total for 5 rounds. Removing all the + // elements would be like starting with an empty queue. + for (i in 0 until N - round - 1) { + assertArrayEquals(queue.peek(), expected.remove()) + queue.remove() + } + queue.close() + } + + // Remove and validate remaining 15 elements. + val queue = newQueueFile() + assertEquals(queue.size(), 15) + assertEquals(queue.size(), expected.size) + while (!expected.isEmpty()) { + assertArrayEquals(queue.peek(), expected.remove()) + queue.remove() + } + queue.close() + + // length() returns 0, but I checked the size w/ 'ls', and it is correct. + // assertEquals(65536, file.length()); + logger.info("Ran in " + ((System.nanoTime() - start) / 1000000) + "ms.") + } + + @Test + fun testFailedAdd() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + queueFile.add(values[252]) + Assert.fail() + } catch (e: IOException) { /* expected */ + } + + braf.rejectCommit = false + + // Allow a subsequent add to succeed. + queueFile.add(values[251]) + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 2) + assertArrayEquals(queueFile.peek(), values[253]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[251]) + } + + @Test + fun testFailedRemoval() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + queueFile.remove() + fail() + } catch (e: IOException) { /* expected */ + } + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 1) + assertArrayEquals(queueFile.peek(), values[253]) + + queueFile.add(values[99]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[99]) + } + + @Test + fun testFailedExpansion() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + // This should trigger an expansion which should fail. + queueFile.add(ByteArray(8000)) + fail() + } catch (e: IOException) { /* expected */ + } + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 1) + assertArrayEquals(queueFile.peek(), values[253]) + assertEquals(queueFile.fileLength, 4096) + + queueFile.add(values[99]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[99]) + } + + @Test + fun removingElementZeroesData() { + val queueFile = newQueueFile(true) + queueFile.add(values[4]) + queueFile.remove() + queueFile.close() + + val source: BufferedSource = Okio.buffer(Okio.source(file)) + source.skip(headerLength.toLong()) + source.skip(Element.HEADER_LENGTH.toLong()) + assertEquals(source.readByteString(4).hex(), "00000000") + } + + @Test + fun removingElementDoesNotZeroData() { + val queueFile = newQueueFile(false) + queueFile.add(values[4]) + queueFile.remove() + queueFile.close() + + val source: BufferedSource = Okio.buffer(Okio.source(file)) + source.skip(headerLength.toLong()) + source.skip(Element.HEADER_LENGTH.toLong()) + assertEquals(source.readByteString(4).hex(), "04030201") + + source.close() + } + + /** + * Exercise a bug where opening a queue whose first or last element's header + * was non contiguous throws an [java.io.EOFException]. + */ + @Test + fun testReadHeadersFromNonContiguousQueueWorks() { + val queueFile = newQueueFile() + + // Fill the queue up to `length - 2` (i.e. remainingBytes() == 2). + for (i in 0..14) { + queueFile.add(values[N - 1]) + } + queueFile.add(values[219]) + + // Remove first item so we have room to add another one without growing the file. + queueFile.remove() + + // Add any element element and close the queue. + queueFile.add(values[6]) + val queueSize = queueFile.size() + queueFile.close() + + // File should not be corrupted. + val queueFile2 = newQueueFile() + assertEquals(queueFile2.size(), queueSize) + } + + @Test + fun testIterator() { + val data = values[10] + + for (i in 0..9) { + val queueFile = newQueueFile() + for (j in 0 until i) { + queueFile.add(data) + } + + var saw = 0 + for (element in queueFile) { + assertArrayEquals(element, data) + saw++ + } + assertEquals(saw, i) + queueFile.close() + file!!.delete() + } + } + + @Test + fun testIteratorNextThrowsWhenEmpty() { + val queueFile = newQueueFile() + + val iterator: Iterator = queueFile.iterator() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorNextThrowsWhenExhausted() { + val queueFile = newQueueFile() + queueFile.add(values[0]) + + val iterator: Iterator = queueFile.iterator() + iterator.next() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorRemove() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + while (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + + assertTrue(queueFile.isEmpty) + } + + @Test + fun testIteratorRemoveDisallowsConcurrentModification() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + iterator.next() + queueFile.remove() + try { + iterator.remove() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorHasNextDisallowsConcurrentModification() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator: Iterator = queueFile.iterator() + iterator.next() + queueFile.remove() + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorDisallowsConcurrentModificationWithClear() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator: Iterator = queueFile.iterator() + iterator.next() + queueFile.clear() + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorOnlyRemovesFromHead() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + iterator.next() + iterator.next() + + try { + iterator.remove() + fail() + } catch (ex: UnsupportedOperationException) { + assertEquals(ex.message, "Removal is only permitted from the head.") + } + } + + @Test + fun iteratorThrowsIOException() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + class BrokenRandomAccessFile(file: File?, mode: String?) : RandomAccessFile(file, mode) { + var fail: Boolean = false + + override fun write(b: ByteArray, off: Int, len: Int) { + if (fail) { + throw IOException() + } + super.write(b, off, len) + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (fail) { + throw IOException() + } + return super.read(b, off, len) + } + } + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + val iterator = queueFile.iterator() + + braf.fail = true + try { + iterator.next() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + + braf.fail = false + iterator.next() + + braf.fail = true + try { + iterator.remove() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + } + + @Test + fun queueToString() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + assertTrue( + queueFile.toString().contains( + "zero=true, length=4096," + + " size=15," + + " first=Element[position=32, length=0], last=Element[position=179, length=14]}" + ) + ) + } + + @Test + fun `wraps elements around when size is specified`() { + val queue = newQueueFile(size = 2) + + for (i in 0 until 3) { + queue.add(values[i]) + } + + // Confirm that first element now is values[1] in the file after wrapping + assertArrayEquals(queue.peek(), values[1]) + queue.remove() + + // Confirm that first element now is values[2] in the file after wrapping + assertArrayEquals(queue.peek(), values[2]) + } + + /** + * A RandomAccessFile that can break when you go to write the COMMITTED + * status. + */ + internal class BrokenRandomAccessFile(file: File?, mode: String?) : RandomAccessFile(file, mode) { + var rejectCommit: Boolean = true + override fun write(b: ByteArray, off: Int, len: Int) { + if (rejectCommit && filePointer == 0L) { + throw IOException("No commit for you!") + } + super.write(b, off, len) + } + } + + companion object { + private val logger: Logger = Logger.getLogger( + QueueFileTest::class.java.name + ) + + /** + * Takes up 33401 bytes in the queue (N*(N+1)/2+4*N). Picked 254 instead of 255 so that the number + * of bytes isn't a multiple of 4. + */ + private const val N = 254 + private val values = Array(N) { i -> + val value = ByteArray(i) + // Example: values[3] = { 3, 2, 1 } + for (ii in 0 until i) value[ii] = (i - ii).toByte() + value + } + } +} diff --git a/sentry/src/test/resources/corrupt_queue_file.txt b/sentry/src/test/resources/corrupt_queue_file.txt new file mode 100644 index 0000000000000000000000000000000000000000..2eca21fb255ad4e651f0a767d5c2c83dc208bb77 GIT binary patch literal 4100 zcmeH@J#WJx5Qa&|O#KNKb8Q(yWt=Hnm9?tW`D`C4l1&p#yi}F)(~Cc%CQ|n{!k#CdW&-irv^oG9c zZy&G&+r|Vxz_2@=Pp(0q*-t*Xa6lI-Z^Kb5Q6vIHfCvx)B0vO)01+SpM1Tko0V42E1bzTiU{pE) literal 0 HcmV?d00001 From 5e52d651f78dd1e67c1bc95e376c07db617fc96b Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 17 Mar 2025 12:37:48 +0000 Subject: [PATCH 21/42] release: 7.22.3 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 936bd3fe290..bc25a92eadc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.3 ### Fixes diff --git a/gradle.properties b/gradle.properties index a45a1152629..0b32bcd6377 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.2 +versionName=7.22.3 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From b14c7bf43d0fbaa9d98335c042a544be343729a4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 19 Mar 2025 23:09:24 +0100 Subject: [PATCH 22/42] Avoid reading floats as ints from the manifest in case it's not necessary (#4266) * Avoid logging an error when a float is passed in the manifest * Update CHANGELOG.md --------- Co-authored-by: Stefano --- CHANGELOG.md | 6 ++++++ .../java/io/sentry/android/core/ManifestMetadataReader.java | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc25a92eadc..b0019bf87a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Avoid logging an error when a float is passed in the manifest ([#4266](https://github.com/getsentry/sentry-java/pull/4266)) + ## 7.22.3 ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 6e8a64530fc..9a27544aeff 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -494,7 +494,10 @@ private static boolean readBool( private static @NotNull Double readDouble( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float - final Double value = ((Number) metadata.getFloat(key, metadata.getInt(key, -1))).doubleValue(); + double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); + if (value == -1) { + value = ((Integer) metadata.getInt(key, -1)).doubleValue(); + } logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } From bd16527ca058925ae08a785a8a9ade3e933b9279 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 20 Mar 2025 10:19:04 +0100 Subject: [PATCH 23/42] fix(session-replay): Do not crash if navigation breadcrumb has no destination (#4185) (#4269) * fix(session-replay): Do not crash if navigation breadcrumb has no destination (#4185) * Do not crash if navigation breadcrumb has not destination * Changelog * Fix test --- CHANGELOG.md | 2 ++ .../android/replay/capture/CaptureStrategy.kt | 10 ++++++-- .../capture/SessionCaptureStrategyTest.kt | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0019bf87a1..950576d24f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixes +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) - Avoid logging an error when a float is passed in the manifest ([#4266](https://github.com/getsentry/sentry-java/pull/4266)) ## 7.22.3 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 660a366ecd2..6efaf47e3cb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -58,6 +58,10 @@ internal interface CaptureStrategy { companion object { private const val BREADCRUMB_START_OFFSET = 100L + // 5 minutes, otherwise relay will just drop it. Can prevent the case where the device + // time is wrong and the segment is too long. + private const val MAX_SEGMENT_DURATION = 1000L * 60 * 5 + fun createSegment( hub: IHub?, options: SentryOptions, @@ -76,7 +80,7 @@ internal interface CaptureStrategy { events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( - duration, + minOf(duration, MAX_SEGMENT_DURATION), currentSegmentTimestamp.time, segmentId, height, @@ -179,7 +183,9 @@ internal interface CaptureStrategy { recordingPayload += rrwebEvent // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation" && + rrwebEvent.data?.getOrElse("to", { null }) is String + ) { urls.add(rrwebEvent.data!!["to"] as String) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index dfe4137fb06..c0fd4f2d043 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -336,6 +336,30 @@ class SessionCaptureStrategyTest { ) } + @Test + fun `does not throw when navigation destination is not a String`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertNull(it.urls?.firstOrNull()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertNull(breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + @Test fun `sets screen from scope as replay url`() { fixture.scope.screen = "MainActivity" From 8a2fd325f8ada1b352d70bb4db486dd965dcecb5 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 20 Mar 2025 10:17:21 +0000 Subject: [PATCH 24/42] release: 7.22.4 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 950576d24f3..af5f5bb52e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.4 ### Fixes diff --git a/gradle.properties b/gradle.properties index 0b32bcd6377..7eb71745b3d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.3 +versionName=7.22.4 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 6e6cea180516409922730aa6823ea8aa7a6af381 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Mar 2025 16:54:08 +0100 Subject: [PATCH 25/42] fix(replay): Change bitmap config to `ARGB_8888` for screenshots (#4282) (#4283) * fix(replay): Change bitmap config to ARGB_8888 for screenshots * Changelog --- CHANGELOG.md | 6 ++++++ .../java/io/sentry/android/replay/ScreenshotRecorder.kt | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af5f5bb52e5..e8fa4ec762a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Session Replay: Change bitmap config to `ARGB_8888` for screenshots ([#4282](https://github.com/getsentry/sentry-java/pull/4282)) + ## 7.22.4 ### Fixes diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ba3cfc71155..98e8d12c743 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -53,13 +53,13 @@ internal class ScreenshotRecorder( Bitmap.createBitmap( 1, 1, - Bitmap.Config.RGB_565 + Bitmap.Config.ARGB_8888 ) } private val screenshot = Bitmap.createBitmap( config.recordingWidth, config.recordingHeight, - Bitmap.Config.RGB_565 + Bitmap.Config.ARGB_8888 ) private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } private val prescaledMatrix by lazy(NONE) { @@ -217,7 +217,9 @@ internal class ScreenshotRecorder( fun close() { unbind(rootView?.get()) rootView?.clear() - screenshot.recycle() + if (!screenshot.isRecycled) { + screenshot.recycle() + } isCapturing.set(false) } From 47274244bce028eccec6722ccda03b4148b8c6f7 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 24 Mar 2025 15:55:03 +0000 Subject: [PATCH 26/42] release: 7.22.5 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fa4ec762a..77e7a2114f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.22.5 ### Fixes diff --git a/gradle.properties b/gradle.properties index 7eb71745b3d..40ebe6be17b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.22.4 +versionName=7.22.5 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 49f0f1c2fdc83e3fbc18d71eefe2a6dd7bf5cd67 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 7 Apr 2025 13:13:58 +0200 Subject: [PATCH 27/42] Compress Screenshots on a background thread (#4295) * Compress Screenshots on a background thread * Update Changelog * Recover APIs used by hybrid SDKs * Recycle bitmap after compression --- CHANGELOG.md | 6 +++ .../core/ScreenshotEventProcessor.java | 15 ++++-- .../core/internal/util/ScreenshotUtils.java | 50 ++++++++++++++++- .../core/internal/util/ScreenshotUtilTest.kt | 47 +++++++++++++--- sentry/api/sentry.api | 3 ++ .../src/main/java/io/sentry/Attachment.java | 54 ++++++++++++++++++- .../java/io/sentry/SentryEnvelopeItem.java | 11 +++- .../java/io/sentry/SentryEnvelopeItemTest.kt | 25 +++++++++ 8 files changed, 197 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e7a2114f3..7eefb6b4398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) + ## 7.22.5 ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 8cdc2461d23..7feecb827c9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -1,10 +1,11 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; +import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; +import android.graphics.Bitmap; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -12,6 +13,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.Debouncer; +import io.sentry.android.core.internal.util.ScreenshotUtils; import io.sentry.protocol.SentryTransaction; import io.sentry.util.HintUtils; import io.sentry.util.Objects; @@ -87,14 +89,19 @@ public ScreenshotEventProcessor( return event; } - final byte[] screenshot = - takeScreenshot( + final Bitmap screenshot = + captureScreenshot( activity, options.getMainThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } - hint.setScreenshot(Attachment.fromScreenshot(screenshot)); + hint.setScreenshot( + Attachment.fromByteProvider( + () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), + "screenshot.png", + "image/png", + false)); hint.set(ANDROID_ACTIVITY, activity); return event; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 45e9d56877d..25d38b64130 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -27,6 +27,10 @@ public class ScreenshotUtils { private static final long CAPTURE_TIMEOUT_MS = 1000; + // Used by Hybrid SDKs + /** + * @noinspection unused + */ public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, @@ -35,12 +39,33 @@ public class ScreenshotUtils { activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider); } + // Used by Hybrid SDKs @SuppressLint("NewApi") public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull IMainThreadChecker mainThreadChecker, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { + + final @Nullable Bitmap screenshot = + captureScreenshot(activity, mainThreadChecker, logger, buildInfoProvider); + return compressBitmapToPng(screenshot, logger); + } + + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + return captureScreenshot( + activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider); + } + + @SuppressLint("NewApi") + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { // We are keeping BuildInfoProvider param for compatibility, as it's being used by // cross-platform SDKs @@ -72,7 +97,7 @@ public class ScreenshotUtils { return null; } - try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + try { // ARGB_8888 -> This configuration is very flexible and offers the best quality final Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); @@ -133,10 +158,31 @@ public class ScreenshotUtils { return null; } } + return bitmap; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + } + return null; + } + /** + * Compresses the supplied Bitmap to a PNG byte array. After compression, the Bitmap will be + * recycled. + * + * @param bitmap The bitmap to compress + * @param logger the logger + * @return the Bitmap in PNG format, or null if the bitmap was null, recycled or compressing faile + */ + public static @Nullable byte[] compressBitmapToPng( + final @Nullable Bitmap bitmap, final @NotNull ILogger logger) { + if (bitmap == null || bitmap.isRecycled()) { + return null; + } + try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { // 0 meaning compress for small size, 100 meaning compress for max quality. // Some formats, like PNG which is lossless, will ignore the quality setting. bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream); + bitmap.recycle(); if (byteArrayOutputStream.size() <= 0) { logger.log(SentryLevel.DEBUG, "Screenshot is 0 bytes, not attaching the image."); @@ -146,7 +192,7 @@ public class ScreenshotUtils { // screenshot png is around ~100-150 kb return byteArrayOutputStream.toByteArray(); } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e); } return null; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt index 18eecf3128d..10369063f6a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt @@ -1,12 +1,14 @@ package io.sentry.android.core.internal.util import android.app.Activity +import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.view.View import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger +import io.sentry.NoOpLogger import io.sentry.android.core.BuildInfoProvider import junit.framework.TestCase.assertNull import org.junit.runner.RunWith @@ -16,7 +18,9 @@ import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue @Config( shadows = [ShadowPixelCopy::class], @@ -32,7 +36,7 @@ class ScreenshotUtilTest { whenever(activity.isDestroyed).thenReturn(false) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -44,7 +48,7 @@ class ScreenshotUtilTest { whenever(activity.window).thenReturn(mock()) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -60,7 +64,7 @@ class ScreenshotUtilTest { whenever(window.peekDecorView()).thenReturn(decorView) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -81,7 +85,7 @@ class ScreenshotUtilTest { whenever(rootView.height).thenReturn(0) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -94,7 +98,7 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } @@ -107,9 +111,40 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } + + @Test + fun `a null bitmap compresses into null`() { + val bytes = ScreenshotUtils.compressBitmapToPng(null, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a recycled bitmap compresses into null`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.recycle() + + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a valid bitmap compresses into a valid bytearray`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNotNull(bytes) + assertTrue(bytes.isNotEmpty()) + } + + @Test + fun `compressBitmapToPng recycles the supplied bitmap`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + assertFalse(bitmap.isRecycled) + ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertTrue(bitmap.isRecycled) + } } class ExampleActivity : Activity() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d26d3474911..02e3e71e9ee 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -11,14 +11,17 @@ public final class io/sentry/Attachment { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V + public fun (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;)V public fun ([BLjava/lang/String;Ljava/lang/String;)V public fun ([BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;Ljava/lang/String;Z)V + public static fun fromByteProvider (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/Attachment; public static fun fromScreenshot ([B)Lio/sentry/Attachment; public static fun fromThreadDump ([B)Lio/sentry/Attachment; public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; + public fun getByteProvider ()Ljava/util/concurrent/Callable; public fun getBytes ()[B public fun getContentType ()Ljava/lang/String; public fun getFilename ()Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Attachment.java b/sentry/src/main/java/io/sentry/Attachment.java index 7a4ec3b99dc..439ad812b0c 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -2,6 +2,7 @@ import io.sentry.protocol.ViewHierarchy; import java.io.File; +import java.util.concurrent.Callable; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -10,6 +11,7 @@ public final class Attachment { private @Nullable byte[] bytes; private final @Nullable JsonSerializable serializable; + private final @Nullable Callable byteProvider; private @Nullable String pathname; private final @NotNull String filename; private final @Nullable String contentType; @@ -84,6 +86,7 @@ public Attachment( final boolean addToTransactions) { this.bytes = bytes; this.serializable = null; + this.byteProvider = null; this.filename = filename; this.contentType = contentType; this.attachmentType = attachmentType; @@ -109,6 +112,33 @@ public Attachment( final boolean addToTransactions) { this.bytes = null; this.serializable = serializable; + this.byteProvider = null; + this.filename = filename; + this.contentType = contentType; + this.attachmentType = attachmentType; + this.addToTransactions = addToTransactions; + } + + /** + * Initializes an Attachment with bytes factory, a filename, a content type, and + * addToTransactions. + * + * @param byteProvider A provider holding the attachment payload + * @param filename The name of the attachment to display in Sentry. + * @param contentType The content type of the attachment. + * @param attachmentType the attachment type. + * @param addToTransactions true if the SDK should add this attachment to every + * {@link ITransaction} or set to false if it shouldn't. + */ + public Attachment( + final @NotNull Callable byteProvider, + final @NotNull String filename, + final @Nullable String contentType, + final @Nullable String attachmentType, + final boolean addToTransactions) { + this.bytes = null; + this.serializable = null; + this.byteProvider = byteProvider; this.filename = filename; this.contentType = contentType; this.attachmentType = attachmentType; @@ -186,6 +216,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.attachmentType = attachmentType; this.addToTransactions = addToTransactions; @@ -212,6 +243,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.addToTransactions = addToTransactions; } @@ -240,6 +272,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.addToTransactions = addToTransactions; this.attachmentType = attachmentType; @@ -310,16 +343,35 @@ boolean isAddToTransactions() { return attachmentType; } + public @Nullable Callable getByteProvider() { + return byteProvider; + } + /** * Creates a new Screenshot Attachment * - * @param screenshotBytes the array bytes + * @param screenshotBytes the array bytes of the PNG screenshot * @return the Attachment */ public static @NotNull Attachment fromScreenshot(final byte[] screenshotBytes) { return new Attachment(screenshotBytes, "screenshot.png", "image/png", false); } + /** + * Creates a new Screenshot Attachment + * + * @param provider the mechanism providing the screenshot payload + * @return the Attachment + */ + public static @NotNull Attachment fromByteProvider( + final @NotNull Callable provider, + final @NotNull String filename, + final @Nullable String contentType, + final boolean addToTransactions) { + return new Attachment( + provider, filename, contentType, DEFAULT_ATTACHMENT_TYPE, addToTransactions); + } + /** * Creates a new View Hierarchy Attachment * diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 7862c8d6643..5301ef7ffbc 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -233,6 +233,7 @@ public static SentryEnvelopeItem fromAttachment( return data; } else if (attachment.getSerializable() != null) { final JsonSerializable serializable = attachment.getSerializable(); + @SuppressWarnings("NullableProblems") final @Nullable byte[] data = JsonSerializationUtils.bytesFrom(serializer, logger, serializable); @@ -243,11 +244,19 @@ public static SentryEnvelopeItem fromAttachment( } } else if (attachment.getPathname() != null) { return readBytesFromFile(attachment.getPathname(), maxAttachmentSize); + } else if (attachment.getByteProvider() != null) { + @SuppressWarnings("NullableProblems") + final @Nullable byte[] data = attachment.getByteProvider().call(); + if (data != null) { + ensureAttachmentSizeLimit( + data.length, maxAttachmentSize, attachment.getFilename()); + return data; + } } throw new SentryEnvelopeException( String.format( "Couldn't attach the attachment %s.\n" - + "Please check that either bytes, serializable or a path is set.", + + "Please check that either bytes, serializable, path or provider is set.", attachment.getFilename())); }); diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 760d1270e5c..2abc62f1df8 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -23,6 +23,7 @@ import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset import java.nio.file.Files +import java.util.concurrent.Callable import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -103,6 +104,30 @@ class SentryEnvelopeItemTest { assertAttachment(attachment, viewHierarchySerialized, item) } + @Test + fun `fromAttachment with byteProvider`() { + val attachment = Attachment( + object : Callable { + override fun call(): ByteArray? { + return byteArrayOf(0x1) + } + }, + fixture.filename, + "text/plain", + "image/png", + false + ) + + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) + + assertAttachment(attachment, byteArrayOf(0x1), item) + } + @Test fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") From 91015a06c5486d5e5ec16bc344b58080c0e334d8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 10 Apr 2025 10:41:38 +0200 Subject: [PATCH 28/42] fix(breadcrumbs): Improve low memory breadcrumb capturing (#4325) * Improve low memory breadcrumb capturing * Changelog * Debounce low memory breadcrumbs --- CHANGELOG.md | 1 + .../AppComponentsBreadcrumbsIntegration.java | 52 +++++++++++-------- ...AppComponentsBreadcrumbsIntegrationTest.kt | 44 +++++++++------- 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eefb6b4398..0e9ecef60f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) +- Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) ## 7.22.5 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index e11bd5d3b9f..b520475ff93 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -12,6 +12,8 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.protocol.Device; import io.sentry.util.Objects; @@ -24,10 +26,17 @@ public final class AppComponentsBreadcrumbsIntegration implements Integration, Closeable, ComponentCallbacks2 { + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; + // pre-allocate hint to avoid creating it every time for the low memory case + private static final @NotNull Hint EMPTY_HINT = new Hint(); + private final @NotNull Context context; private @Nullable IHub hub; private @Nullable SentryAndroidOptions options; + private final @NotNull Debouncer trimMemoryDebouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); + public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { this.context = Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); @@ -91,42 +100,43 @@ public void onConfigurationChanged(@NotNull Configuration newConfig) { @Override public void onLowMemory() { - final long now = System.currentTimeMillis(); - executeInBackground(() -> captureLowMemoryBreadcrumb(now, null)); + // we do this in onTrimMemory below already, this is legacy API (14 or below) } @Override public void onTrimMemory(final int level) { + if (level < TRIM_MEMORY_BACKGROUND) { + // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or + // TRIM_MEMORY_COMPLETE. + // Release as much memory as the process can. + + // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and + // TRIM_MEMORY_RUNNING_CRITICAL. + // Release any memory that your app doesn't need to run. + // So they are still not so critical at the point of killing the process. + // https://developer.android.com/topic/performance/memory + return; + } + + if (trimMemoryDebouncer.checkForDebounce()) { + // if we received trim_memory within 1 minute time, ignore this call + return; + } + final long now = System.currentTimeMillis(); executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); } - private void captureLowMemoryBreadcrumb(final long timeMs, final @Nullable Integer level) { + private void captureLowMemoryBreadcrumb(final long timeMs, final int level) { if (hub != null) { final Breadcrumb breadcrumb = new Breadcrumb(timeMs); - if (level != null) { - // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or - // TRIM_MEMORY_COMPLETE. - // Release as much memory as the process can. - - // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and - // TRIM_MEMORY_RUNNING_CRITICAL. - // Release any memory that your app doesn't need to run. - // So they are still not so critical at the point of killing the process. - // https://developer.android.com/topic/performance/memory - - if (level < TRIM_MEMORY_BACKGROUND) { - return; - } - breadcrumb.setData("level", level); - } - breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); breadcrumb.setMessage("Low memory"); breadcrumb.setData("action", "LOW_MEMORY"); + breadcrumb.setData("level", level); breadcrumb.setLevel(SentryLevel.WARNING); - hub.addBreadcrumb(breadcrumb); + hub.addBreadcrumb(breadcrumb, EMPTY_HINT); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt index 8f45c238029..3eba70b5a1b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import java.lang.NullPointerException import kotlin.test.Test @@ -95,24 +96,6 @@ class AppComponentsBreadcrumbsIntegrationTest { sut.close() } - @Test - fun `When low memory event, a breadcrumb with type, category and level should be set`() { - val sut = fixture.getSut() - val options = SentryAndroidOptions().apply { - executorService = ImmediateExecutorService() - } - val hub = mock() - sut.register(hub, options) - sut.onLowMemory() - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.WARNING, it.level) - } - ) - } - @Test fun `When trim memory event with level, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() @@ -127,7 +110,8 @@ class AppComponentsBreadcrumbsIntegrationTest { assertEquals("device.event", it.category) assertEquals("system", it.type) assertEquals(SentryLevel.WARNING, it.level) - } + }, + anyOrNull() ) } @@ -162,4 +146,26 @@ class AppComponentsBreadcrumbsIntegrationTest { anyOrNull() ) } + + @Test + fun `low memory changes are debounced`() { + val sut = fixture.getSut() + + val scopes = mock() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + sut.register(scopes, options) + sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) + sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + + // should only add the first crumb + verify(scopes).addBreadcrumb( + check { + assertEquals(it.data["level"], 40) + }, + anyOrNull() + ) + verifyNoMoreInteractions(scopes) + } } From 2ed8422c7837efa1746e9310bc34fa3cbe1c0307 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 14 Apr 2025 14:58:46 +0200 Subject: [PATCH 29/42] perf(breadcrumbs): Make SystemEventsBreadcrumbsIntegration faster (#4330) * Make SystemEventsBreadcrumbsIntegration faster * Changelog * Fix leak --- CHANGELOG.md | 1 + .../SystemEventsBreadcrumbsIntegration.java | 105 ++++++++++++------ .../SystemEventsBreadcrumbsIntegrationTest.kt | 48 ++++++++ 3 files changed, 123 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9ecef60f1..fc18cdc1d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) - Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) +- Make `SystemEventsBreadcrumbsIntegration` faster ([#4330](https://github.com/getsentry/sentry-java/pull/4330)) ## 7.22.5 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index cfa61454bc1..179719b0f55 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -37,7 +37,7 @@ import io.sentry.util.StringUtils; import java.io.Closeable; import java.io.IOException; -import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -53,19 +53,25 @@ public final class SystemEventsBreadcrumbsIntegration implements Integration, Cl private @Nullable SentryAndroidOptions options; - private final @NotNull List actions; + private final @NotNull String[] actions; private boolean isClosed = false; private final @NotNull Object startLock = new Object(); public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { - this(context, getDefaultActions()); + this(context, getDefaultActionsInternal()); + } + + private SystemEventsBreadcrumbsIntegration( + final @NotNull Context context, final @NotNull String[] actions) { + this.context = ContextUtils.getApplicationContext(context); + this.actions = actions; } public SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull List actions) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - this.actions = Objects.requireNonNull(actions, "Actions list is required"); + this.context = ContextUtils.getApplicationContext(context); + this.actions = new String[actions.size()]; + actions.toArray(this.actions); } @Override @@ -127,28 +133,32 @@ private void startSystemEventsReceiver( } } - @SuppressWarnings("deprecation") public static @NotNull List getDefaultActions() { - final List actions = new ArrayList<>(); - actions.add(ACTION_SHUTDOWN); - actions.add(ACTION_AIRPLANE_MODE_CHANGED); - actions.add(ACTION_BATTERY_CHANGED); - actions.add(ACTION_CAMERA_BUTTON); - actions.add(ACTION_CONFIGURATION_CHANGED); - actions.add(ACTION_DATE_CHANGED); - actions.add(ACTION_DEVICE_STORAGE_LOW); - actions.add(ACTION_DEVICE_STORAGE_OK); - actions.add(ACTION_DOCK_EVENT); - actions.add(ACTION_DREAMING_STARTED); - actions.add(ACTION_DREAMING_STOPPED); - actions.add(ACTION_INPUT_METHOD_CHANGED); - actions.add(ACTION_LOCALE_CHANGED); - actions.add(ACTION_SCREEN_OFF); - actions.add(ACTION_SCREEN_ON); - actions.add(ACTION_TIMEZONE_CHANGED); - actions.add(ACTION_TIME_CHANGED); - actions.add("android.os.action.DEVICE_IDLE_MODE_CHANGED"); - actions.add("android.os.action.POWER_SAVE_MODE_CHANGED"); + return Arrays.asList(getDefaultActionsInternal()); + } + + @SuppressWarnings("deprecation") + private static @NotNull String[] getDefaultActionsInternal() { + final String[] actions = new String[19]; + actions[0] = ACTION_SHUTDOWN; + actions[1] = ACTION_AIRPLANE_MODE_CHANGED; + actions[2] = ACTION_BATTERY_CHANGED; + actions[3] = ACTION_CAMERA_BUTTON; + actions[4] = ACTION_CONFIGURATION_CHANGED; + actions[5] = ACTION_DATE_CHANGED; + actions[6] = ACTION_DEVICE_STORAGE_LOW; + actions[7] = ACTION_DEVICE_STORAGE_OK; + actions[8] = ACTION_DOCK_EVENT; + actions[9] = ACTION_DREAMING_STARTED; + actions[10] = ACTION_DREAMING_STOPPED; + actions[11] = ACTION_INPUT_METHOD_CHANGED; + actions[12] = ACTION_LOCALE_CHANGED; + actions[13] = ACTION_SCREEN_OFF; + actions[14] = ACTION_SCREEN_ON; + actions[15] = ACTION_TIMEZONE_CHANGED; + actions[16] = ACTION_TIME_CHANGED; + actions[17] = "android.os.action.DEVICE_IDLE_MODE_CHANGED"; + actions[18] = "android.os.action.POWER_SAVE_MODE_CHANGED"; return actions; } @@ -204,10 +214,43 @@ public void onReceive(final Context context, final @NotNull Intent intent) { hub.addBreadcrumb(breadcrumb, hint); }); } catch (Throwable t) { - options - .getLogger() - .log(SentryLevel.ERROR, t, "Failed to submit system event breadcrumb action."); + // ignored + } + } + + // in theory this should be ThreadLocal, but we won't have more than 1 thread accessing it, + // so we save some memory here and CPU cycles. 64 is because all intent actions we subscribe for + // are less than 64 chars. We also don't care about encoding as those are always UTF. + // TODO: _MULTI_THREADED_EXECUTOR_ + private final char[] buf = new char[64]; + + @TestOnly + @Nullable + String getStringAfterDotFast(final @Nullable String str) { + if (str == null) { + return null; } + + final int len = str.length(); + int bufIndex = buf.length; + + // the idea here is to iterate from the end of the string and copy the characters to a + // pre-allocated buffer in reverse order. When we find a dot, we create a new string + // from the buffer. This way we use a fixed size buffer and do a bare minimum of iterations. + for (int i = len - 1; i >= 0; i--) { + final char c = str.charAt(i); + if (c == '.') { + return new String(buf, bufIndex, buf.length - bufIndex); + } + if (bufIndex == 0) { + // Overflow — fallback to safe version + return StringUtils.getStringAfterDot(str); + } + buf[--bufIndex] = c; + } + + // No dot found — return original + return str; } private @NotNull Breadcrumb createBreadcrumb( @@ -218,7 +261,7 @@ public void onReceive(final Context context, final @NotNull Intent intent) { final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - final String shortAction = StringUtils.getStringAfterDot(action); + final String shortAction = getStringAfterDotFast(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index 3dfca15fdb3..c19c4aad108 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -102,6 +102,8 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) val intent = Intent().apply { action = Intent.ACTION_TIME_CHANGED + putExtra("test", 10) + putExtra("test2", 20) } sut.receiver!!.onReceive(fixture.context, intent) @@ -180,4 +182,50 @@ class SystemEventsBreadcrumbsIntegrationTest { assertFalse(fixture.options.isEnableSystemEventBreadcrumbs) } + + @Test + fun `when str has full package, return last string after dot`() { + val sut = fixture.getSut() + + sut.register(fixture.scopes, fixture.options) + + assertEquals("DEVICE_IDLE_MODE_CHANGED", sut.receiver?.getStringAfterDotFast("io.sentry.DEVICE_IDLE_MODE_CHANGED")) + assertEquals("POWER_SAVE_MODE_CHANGED", sut.receiver?.getStringAfterDotFast("io.sentry.POWER_SAVE_MODE_CHANGED")) + } + + @Test + fun `when str is null, return null`() { + val sut = fixture.getSut() + + sut.register(fixture.scopes, fixture.options) + + assertNull(sut.receiver?.getStringAfterDotFast(null)) + } + + @Test + fun `when str is empty, return the original str`() { + val sut = fixture.getSut() + + sut.register(fixture.scopes, fixture.options) + + assertEquals("", sut.receiver?.getStringAfterDotFast("")) + } + + @Test + fun `when str ends with a dot, return empty str`() { + val sut = fixture.getSut() + + sut.register(fixture.scopes, fixture.options) + + assertEquals("", sut.receiver?.getStringAfterDotFast("io.sentry.")) + } + + @Test + fun `when str has no dots, return the original str`() { + val sut = fixture.getSut() + + sut.register(fixture.scopes, fixture.options) + + assertEquals("iosentry", sut.receiver?.getStringAfterDotFast("iosentry")) + } } From 46c831a6690e5a65c63abc139912f0be66d88061 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 17 Apr 2025 11:44:44 +0200 Subject: [PATCH 30/42] fix(breadcrumbs): Unregister SystemEventsBroadcastReceiver when entering background (#4338) --- CHANGELOG.md | 2 + .../android/core/AppLifecycleIntegration.java | 5 +- .../SystemEventsBreadcrumbsIntegration.java | 236 ++++++++++++++---- .../core/SessionTrackingIntegrationTest.kt | 4 +- .../SystemEventsBreadcrumbsIntegrationTest.kt | 210 +++++++++++++++- 5 files changed, 399 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc18cdc1d3d..20316749204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) - Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) - Make `SystemEventsBreadcrumbsIntegration` faster ([#4330](https://github.com/getsentry/sentry-java/pull/4330)) +- Fix unregister `SystemEventsBroadcastReceiver` when entering background ([#4338](https://github.com/getsentry/sentry-java/pull/4338)) + - This should reduce ANRs seen with this class in the stack trace for Android 14 and above ## 7.22.5 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 f730f4bc76a..d7ff094dba6 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 @@ -69,9 +69,8 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio options .getLogger() .log( - SentryLevel.INFO, - "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed", - e); + SentryLevel.WARNING, + "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed"); } catch (IllegalStateException e) { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 179719b0f55..ac605b84149 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -25,6 +25,10 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IHub; @@ -32,6 +36,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.util.Objects; import io.sentry.util.StringUtils; @@ -49,13 +54,21 @@ public final class SystemEventsBreadcrumbsIntegration implements Integration, Cl private final @NotNull Context context; - @TestOnly @Nullable SystemEventsBroadcastReceiver receiver; + @TestOnly @Nullable volatile SystemEventsBroadcastReceiver receiver; + + @TestOnly @Nullable volatile ReceiverLifecycleHandler lifecycleHandler; + + private final @NotNull MainLooperHandler handler; private @Nullable SentryAndroidOptions options; + private @Nullable IHub hub; + private final @NotNull String[] actions; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + private volatile boolean isClosed = false; + private volatile boolean isStopped = false; + private volatile IntentFilter filter = null; + private final @NotNull Object receiverLock = new Object(); public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { this(context, getDefaultActionsInternal()); @@ -63,8 +76,16 @@ public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { private SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull String[] actions) { + this(context, actions, new MainLooperHandler()); + } + + SystemEventsBreadcrumbsIntegration( + final @NotNull Context context, + final @NotNull String[] actions, + final @NotNull MainLooperHandler handler) { this.context = ContextUtils.getApplicationContext(context); this.actions = actions; + this.handler = handler; } public SystemEventsBreadcrumbsIntegration( @@ -72,6 +93,7 @@ public SystemEventsBreadcrumbsIntegration( this.context = ContextUtils.getApplicationContext(context); this.actions = new String[actions.size()]; actions.toArray(this.actions); + this.handler = new MainLooperHandler(); } @Override @@ -81,6 +103,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); + this.hub = hub; this.options .getLogger() @@ -90,46 +113,170 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio this.options.isEnableSystemEventBreadcrumbs()); if (this.options.isEnableSystemEventBreadcrumbs()) { + addLifecycleObserver(this.options); + registerReceiver(this.hub, this.options, /* reportAsNewIntegration = */ true); + } + } - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startSystemEventsReceiver(hub, (SentryAndroidOptions) options); + private void registerReceiver( + final @NotNull IHub hub, + final @NotNull SentryAndroidOptions options, + final boolean reportAsNewIntegration) { + + if (!options.isEnableSystemEventBreadcrumbs()) { + return; + } + + synchronized (receiverLock) { + if (isClosed || isStopped || receiver != null) { + return; + } + } + + try { + options + .getExecutorService() + .submit( + () -> { + synchronized (receiverLock) { + if (isClosed || isStopped || receiver != null) { + return; + } + + receiver = new SystemEventsBroadcastReceiver(hub, options); + if (filter == null) { + filter = new IntentFilter(); + for (String item : actions) { + filter.addAction(item); } } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start SystemEventsBreadcrumbsIntegration on executor thread.", - e); - } + try { + // registerReceiver can throw SecurityException but it's not documented in the + // official docs + ContextUtils.registerReceiver(context, options, receiver, filter); + if (reportAsNewIntegration) { + options + .getLogger() + .log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion("SystemEventsBreadcrumbs"); + } + } catch (Throwable e) { + options.setEnableSystemEventBreadcrumbs(false); + options + .getLogger() + .log( + SentryLevel.ERROR, + "Failed to initialize SystemEventsBreadcrumbsIntegration.", + e); + } + } + }); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to start SystemEventsBreadcrumbsIntegration on executor thread."); } } - private void startSystemEventsReceiver( - final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { - receiver = new SystemEventsBroadcastReceiver(hub, options); - final IntentFilter filter = new IntentFilter(); - for (String item : actions) { - filter.addAction(item); + private void unregisterReceiver() { + final @Nullable SystemEventsBroadcastReceiver receiverRef; + synchronized (receiverLock) { + isStopped = true; + receiverRef = receiver; + receiver = null; } + + if (receiverRef != null) { + context.unregisterReceiver(receiverRef); + } + } + + // TODO: this duplicates a lot of AppLifecycleIntegration. We should register once on init + // and multiplex to different listeners rather. + private void addLifecycleObserver(final @NotNull SentryAndroidOptions options) { try { - // registerReceiver can throw SecurityException but it's not documented in the official docs - ContextUtils.registerReceiver(context, options, receiver, filter); - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("SystemEventsBreadcrumbs"); + Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); + Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); + if (AndroidMainThreadChecker.getInstance().isMainThread()) { + addObserverInternal(options); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + handler.post(() -> addObserverInternal(options)); + } + } catch (ClassNotFoundException e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "androidx.lifecycle is not available, SystemEventsBreadcrumbsIntegration won't be able" + + " to register/unregister an internal BroadcastReceiver. This may result in an" + + " increased ANR rate on Android 14 and above."); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + "SystemEventsBreadcrumbsIntegration could not register lifecycle observer", + e); + } + } + + private void addObserverInternal(final @NotNull SentryAndroidOptions options) { + lifecycleHandler = new ReceiverLifecycleHandler(); + + try { + ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleHandler); } catch (Throwable e) { - options.setEnableSystemEventBreadcrumbs(false); + // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in + // connection with conflicting dependencies of the androidx.lifecycle. + // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 + lifecycleHandler = null; options .getLogger() - .log(SentryLevel.ERROR, "Failed to initialize SystemEventsBreadcrumbsIntegration.", e); + .log( + SentryLevel.ERROR, + "SystemEventsBreadcrumbsIntegration failed to get Lifecycle and could not install lifecycle observer.", + e); + } + } + + private void removeLifecycleObserver() { + if (lifecycleHandler != null) { + if (AndroidMainThreadChecker.getInstance().isMainThread()) { + removeObserverInternal(); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + handler.post(() -> removeObserverInternal()); + } + } + } + + private void removeObserverInternal() { + final @Nullable ReceiverLifecycleHandler watcherRef = lifecycleHandler; + if (watcherRef != null) { + ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); + } + lifecycleHandler = null; + } + + @Override + public void close() throws IOException { + synchronized (receiverLock) { + isClosed = true; + filter = null; + } + + removeLifecycleObserver(); + unregisterReceiver(); + + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); } } @@ -162,18 +309,23 @@ private void startSystemEventsReceiver( return actions; } - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (receiver != null) { - context.unregisterReceiver(receiver); - receiver = null; + final class ReceiverLifecycleHandler implements DefaultLifecycleObserver { + @Override + public void onStart(@NonNull LifecycleOwner owner) { + if (hub == null || options == null) { + return; + } - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); + synchronized (receiverLock) { + isStopped = false; } + + registerReceiver(hub, options, /* reportAsNewIntegration = */ false); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + unregisterReceiver(); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index e6d3dfadd7e..d47ca2076cf 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.lifecycle.Lifecycle.Event.ON_START import androidx.lifecycle.Lifecycle.Event.ON_STOP import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CheckIn @@ -24,7 +25,6 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.transport.RateLimiter import org.junit.runner.RunWith -import org.mockito.kotlin.mock import org.robolectric.annotation.Config import java.util.LinkedList import kotlin.test.BeforeTest @@ -116,7 +116,7 @@ class SessionTrackingIntegrationTest { } private fun setupLifecycle(options: SentryOptions): LifecycleRegistry { - val lifecycle = LifecycleRegistry(mock()) + val lifecycle = LifecycleRegistry(ProcessLifecycleOwner.get()) val lifecycleWatcher = ( options.integrations.find { it is AppLifecycleIntegration diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index c19c4aad108..956f3dc1cc1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -3,6 +3,8 @@ package io.sentry.android.core import android.content.Context import android.content.Intent import android.os.BatteryManager +import android.os.Build +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IHub @@ -16,9 +18,13 @@ import org.mockito.kotlin.anyOrNull 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.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -26,19 +32,30 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) class SystemEventsBreadcrumbsIntegrationTest { private class Fixture { val context = mock() var options = SentryAndroidOptions() val hub = mock() - - fun getSut(enableSystemEventBreadcrumbs: Boolean = true, executorService: ISentryExecutorService = ImmediateExecutorService()): SystemEventsBreadcrumbsIntegration { + lateinit var handler: MainLooperHandler + + fun getSut( + enableSystemEventBreadcrumbs: Boolean = true, + executorService: ISentryExecutorService = ImmediateExecutorService(), + mockHandler: Boolean = true + ): SystemEventsBreadcrumbsIntegration { + handler = if (mockHandler) mock() else MainLooperHandler() options = SentryAndroidOptions().apply { isEnableSystemEventBreadcrumbs = enableSystemEventBreadcrumbs this.executorService = executorService } - return SystemEventsBreadcrumbsIntegration(context) + return SystemEventsBreadcrumbsIntegration( + context, + SystemEventsBreadcrumbsIntegration.getDefaultActions().toTypedArray(), + handler + ) } } @@ -50,7 +67,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) - verify(fixture.context).registerReceiver(any(), any()) + verify(fixture.context).registerReceiver(any(), any(), any()) assertNotNull(sut.receiver) } @@ -69,7 +86,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) - verify(fixture.context, never()).registerReceiver(any(), any()) + verify(fixture.context, never()).registerReceiver(any(), any(), any()) assertNull(sut.receiver) } @@ -176,7 +193,7 @@ class SystemEventsBreadcrumbsIntegrationTest { @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() - whenever(fixture.context.registerReceiver(any(), any())).thenThrow(SecurityException()) + whenever(fixture.context.registerReceiver(any(), any(), any())).thenThrow(SecurityException()) sut.register(fixture.hub, fixture.options) @@ -187,7 +204,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `when str has full package, return last string after dot`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + sut.register(fixture.hub, fixture.options) assertEquals("DEVICE_IDLE_MODE_CHANGED", sut.receiver?.getStringAfterDotFast("io.sentry.DEVICE_IDLE_MODE_CHANGED")) assertEquals("POWER_SAVE_MODE_CHANGED", sut.receiver?.getStringAfterDotFast("io.sentry.POWER_SAVE_MODE_CHANGED")) @@ -197,7 +214,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `when str is null, return null`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + sut.register(fixture.hub, fixture.options) assertNull(sut.receiver?.getStringAfterDotFast(null)) } @@ -206,7 +223,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `when str is empty, return the original str`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + sut.register(fixture.hub, fixture.options) assertEquals("", sut.receiver?.getStringAfterDotFast("")) } @@ -215,7 +232,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `when str ends with a dot, return empty str`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + sut.register(fixture.hub, fixture.options) assertEquals("", sut.receiver?.getStringAfterDotFast("io.sentry.")) } @@ -224,8 +241,179 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `when str has no dots, return the original str`() { val sut = fixture.getSut() - sut.register(fixture.scopes, fixture.options) + sut.register(fixture.hub, fixture.options) assertEquals("iosentry", sut.receiver?.getStringAfterDotFast("iosentry")) } + + @Test + fun `When integration is added, lifecycle handler should be started`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + } + + @Test + fun `When system events breadcrumbs are disabled, lifecycle handler should not be started`() { + val sut = fixture.getSut() + fixture.options.apply { + isEnableSystemEventBreadcrumbs = false + } + + sut.register(fixture.hub, fixture.options) + + assertNull(sut.lifecycleHandler) + } + + @Test + fun `When integration is closed, lifecycle handler should be closed`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + + sut.close() + + assertNull(sut.lifecycleHandler) + } + + @Test + fun `When integration is registered from a background thread, post on the main thread`() { + val sut = fixture.getSut() + val latch = CountDownLatch(1) + + Thread { + sut.register(fixture.hub, fixture.options) + latch.countDown() + }.start() + + latch.await() + + verify(fixture.handler).post(any()) + } + + @Test + fun `When integration is closed from a background thread, post on the main thread`() { + val sut = fixture.getSut() + val latch = CountDownLatch(1) + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + + Thread { + sut.close() + latch.countDown() + }.start() + + latch.await() + + verify(fixture.handler).post(any()) + } + + @Test + fun `When integration is closed from a background thread, watcher is set to null`() { + val sut = fixture.getSut(mockHandler = false) + val latch = CountDownLatch(1) + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + + Thread { + sut.close() + latch.countDown() + }.start() + + latch.await() + + // ensure all messages on main looper got processed + shadowOf(Looper.getMainLooper()).idle() + + assertNull(sut.lifecycleHandler) + } + + @Test + fun `when enters background unregisters receiver`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + sut.lifecycleHandler!!.onStop(mock()) + + verify(fixture.context).unregisterReceiver(any()) + assertNull(sut.receiver) + } + + @Test + fun `when enters foreground registers receiver`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + verify(fixture.context).registerReceiver(any(), any(), any()) + + sut.lifecycleHandler!!.onStop(mock()) + sut.lifecycleHandler!!.onStart(mock()) + + verify(fixture.context, times(2)).registerReceiver(any(), any(), any()) + assertNotNull(sut.receiver) + } + + @Test + fun `when enters foreground after register does not recreate the receiver`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + verify(fixture.context).registerReceiver(any(), any(), any()) + val receiver = sut.receiver + + sut.lifecycleHandler!!.onStart(mock()) + assertEquals(receiver, sut.receiver) + } + + @Test + fun `when goes background right after entering foreground, receiver is not registered`() { + val deferredExecutorService = DeferredExecutorService() + val sut = fixture.getSut(executorService = deferredExecutorService) + sut.register(fixture.hub, fixture.options) + deferredExecutorService.runAll() + assertNotNull(sut.receiver) + + sut.lifecycleHandler!!.onStop(mock()) + sut.lifecycleHandler!!.onStart(mock()) + assertNull(sut.receiver) + sut.lifecycleHandler!!.onStop(mock()) + deferredExecutorService.runAll() + assertNull(sut.receiver) + } + + @Test + fun `when enters foreground right after closing, receiver is not registered`() { + val deferredExecutorService = DeferredExecutorService() + val latch = CountDownLatch(1) + + val sut = fixture.getSut(executorService = deferredExecutorService, mockHandler = false) + sut.register(fixture.hub, fixture.options) + deferredExecutorService.runAll() + assertNotNull(sut.receiver) + + Thread { + sut.close() + latch.countDown() + }.start() + + latch.await() + + sut.lifecycleHandler!!.onStart(mock()) + assertNull(sut.receiver) + deferredExecutorService.runAll() + + shadowOf(Looper.getMainLooper()).idle() + + assertNull(sut.receiver) + assertNull(sut.lifecycleHandler) + } } From 940b89ad7b93e6b96632600cceab882dbac08a07 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 22 Apr 2025 15:08:30 +0200 Subject: [PATCH 31/42] perf(modules): Pre-load modules on a background thread (#4348) * perf(modules): Pre-load modules on a background thread * Changelog * Use a simple Thread instead of executor service --- CHANGELOG.md | 1 + .../internal/modules/AssetsModulesLoader.java | 4 ++++ .../io/sentry/internal/modules/ModulesLoader.java | 15 +++++++++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20316749204..8602fbf9145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Make `SystemEventsBreadcrumbsIntegration` faster ([#4330](https://github.com/getsentry/sentry-java/pull/4330)) - Fix unregister `SystemEventsBroadcastReceiver` when entering background ([#4338](https://github.com/getsentry/sentry-java/pull/4338)) - This should reduce ANRs seen with this class in the stack trace for Android 14 and above +- Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) ## 7.22.5 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java index b6374a32e36..05bb75a60f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java @@ -21,6 +21,10 @@ public final class AssetsModulesLoader extends ModulesLoader { public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { super(logger); this.context = ContextUtils.getApplicationContext(context); + + // pre-load modules on a bg thread to avoid doing so on the main thread in case of a crash/error + //noinspection Convert2MethodRef + new Thread(() -> getOrLoadModules()).start(); } @Override diff --git a/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java b/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java index 8b6bc9ba37e..fdc3fc7a1bc 100644 --- a/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java +++ b/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java @@ -1,7 +1,9 @@ package io.sentry.internal.modules; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; +import io.sentry.util.AutoClosableReentrantLock; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -21,7 +23,9 @@ public abstract class ModulesLoader implements IModulesLoader { public static final String EXTERNAL_MODULES_FILENAME = "sentry-external-modules.txt"; protected final @NotNull ILogger logger; - private @Nullable Map cachedModules = null; + + private final @NotNull AutoClosableReentrantLock modulesLock = new AutoClosableReentrantLock(); + private volatile @Nullable Map cachedModules = null; public ModulesLoader(final @NotNull ILogger logger) { this.logger = logger; @@ -29,10 +33,13 @@ public ModulesLoader(final @NotNull ILogger logger) { @Override public @Nullable Map getOrLoadModules() { - if (cachedModules != null) { - return cachedModules; + if (cachedModules == null) { + try (final @NotNull ISentryLifecycleToken ignored = modulesLock.acquire()) { + if (cachedModules == null) { + cachedModules = loadModules(); + } + } } - cachedModules = loadModules(); return cachedModules; } From ac2561c029850ed2ec4bc71846e65e4efe194fea Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 5 Jun 2025 17:07:03 +0200 Subject: [PATCH 32/42] fix(replay): Inconsistent segment_id (#4471) --- CHANGELOG.md | 1 + .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 1 - .../io/sentry/android/replay/capture/BufferCaptureStrategy.kt | 4 ++-- .../sentry/android/replay/capture/SessionCaptureStrategy.kt | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8602fbf9145..41e257d3326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fix unregister `SystemEventsBroadcastReceiver` when entering background ([#4338](https://github.com/getsentry/sentry-java/pull/4338)) - This should reduce ANRs seen with this class in the stack trace for Android 14 and above - Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) +- Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) ## 7.22.5 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 9caf92fa20f..6dd7906d305 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -109,7 +109,6 @@ internal abstract class BaseCaptureStrategy( override fun stop() { cache?.close() - currentSegment = -1 replayStartTimestamp.set(0) segmentTimestamp = null currentReplayId = SentryId.EMPTY_ID diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 996e31afbd8..79bebf1d88e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -57,6 +57,7 @@ internal class BufferCaptureStrategy( val replayCacheDir = cache?.replayCacheDir replayExecutor.submitSafely(options, "$TAG.stop") { FileUtils.deleteRecursively(replayCacheDir) + currentSegment = -1 } super.stop() } @@ -197,7 +198,6 @@ internal class BufferCaptureStrategy( } else { DateUtils.getDateTime(now - errorReplayDuration) } - val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId val height = this.recorderConfig.recordingHeight @@ -205,7 +205,7 @@ internal class BufferCaptureStrategy( replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal(duration, currentSegmentTimestamp, replayId, currentSegment, height, width) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 03ca0cdf552..f28bd9f9c04 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -60,6 +60,7 @@ internal class SessionCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(hub) } + currentSegment = -1 FileUtils.deleteRecursively(replayCacheDir) } hub?.configureScope { it.replayId = SentryId.EMPTY_ID } @@ -137,14 +138,13 @@ internal class SessionCaptureStrategy( private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp ?: return - val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal(duration, currentSegmentTimestamp, replayId, currentSegment, height, width) onSegmentCreated(segment) } } From 3f751c9924bbb333bb8d01a2086164e85a0378d3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 6 Jun 2025 12:29:04 +0200 Subject: [PATCH 33/42] fix(replay): Do not capture replay for cached events (#4474) * fix(replay): Do not capture replay for cached events * Changelog * Formatting * Wording * Still caputre replay for outbox events --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 11 ++-- .../test/java/io/sentry/SentryClientTest.kt | 50 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e257d3326..77b7c4ebae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - This should reduce ANRs seen with this class in the stack trace for Android 14 and above - Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) - Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) +- Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) ## 7.22.5 diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 8dde6f2f14c..fb4be7ba0fc 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -3,7 +3,9 @@ import io.sentry.clientreport.DiscardReason; import io.sentry.exception.SentryEnvelopeException; import io.sentry.hints.AbnormalExit; +import io.sentry.hints.ApplyScopeData; import io.sentry.hints.Backfillable; +import io.sentry.hints.Cached; import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.TransactionEnd; import io.sentry.metrics.EncodedMetrics; @@ -198,9 +200,12 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } final boolean isBackfillable = HintUtils.hasType(hint, Backfillable.class); - // if event is backfillable we don't wanna trigger capture replay, because it's an event from - // the past - if (event != null && !isBackfillable && (event.isErrored() || event.isCrashed())) { + final boolean isCached = + HintUtils.hasType(hint, Cached.class) && !HintUtils.hasType(hint, ApplyScopeData.class); + // if event is backfillable or cached we don't wanna trigger capture replay, because it's + // an event from the past. If it's cached, but with ApplyScopeData, it comes from the outbox + // folder and we still want to capture replay (e.g. a native captureException error) + if (event != null && !isBackfillable && !isCached && (event.isErrored() || event.isCrashed())) { options.getReplayController().captureReplay(event.isCrashed()); } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index fb4f5ae8733..4ebb09db6ed 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -2800,6 +2800,52 @@ class SentryClientTest { assertFalse(called) } + @Test + fun `does not captureReplay for cached events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + }, + HintUtils.createWithTypeCheckHint(CachedHint()) + ) + assertFalse(called) + } + + @Test + fun `captures replay for cached events with apply scope`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + }, + HintUtils.createWithTypeCheckHint(CachedWithApplyScopeHint()) + ) + assertTrue(called) + } + @Test fun `when beforeSendReplay is set, callback is invoked`() { var invoked = false @@ -3123,6 +3169,10 @@ class SentryClientTest { private class BackfillableHint : Backfillable { override fun shouldEnrich(): Boolean = false } + + private class CachedHint : Cached + + private class CachedWithApplyScopeHint : Cached, ApplyScopeData } class DropEverythingEventProcessor : EventProcessor { From 5b51c8f47fb645a71e9fb48a4c7017a9ebc93cb9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 11 Jun 2025 14:14:06 +0200 Subject: [PATCH 34/42] fix(replay): Fix crash on devices with the Unisoc/Spreadtrum T606 chipset (#4477) * fix(replay): Do not capture replay for cached events * Changelog * Formatting * Wording * Still caputre replay for outbox events * fix(replay): Fix crash on devices with the Unisoc/Spreadtrum T606 chipset * typo * Changelog * Address PR feedback (#4481) * Address PR feeback * Cleanup --------- Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../android/replay/util/SystemProperties.kt | 21 +++++++++++++++++++ .../replay/video/SimpleVideoEncoder.kt | 16 +++++++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b7c4ebae1..c9199fafb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) - Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) - Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) +- Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) ## 7.22.5 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt new file mode 100644 index 00000000000..2de284cf642 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt @@ -0,0 +1,21 @@ +package io.sentry.android.replay.util + +import android.os.Build + +internal object SystemProperties { + enum class Property { + SOC_MODEL, + SOC_MANUFACTURER + } + + fun get(key: Property, defaultValue: String = ""): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + when (key) { + Property.SOC_MODEL -> Build.SOC_MODEL + Property.SOC_MANUFACTURER -> Build.SOC_MANUFACTURER + } + } else { + defaultValue + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 0a535a439c7..0d1e5a57a69 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -39,6 +39,7 @@ import android.os.Build import android.view.Surface import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions +import io.sentry.android.replay.util.SystemProperties import java.io.File import java.nio.ByteBuffer import kotlin.LazyThreadSafetyMode.NONE @@ -155,11 +156,20 @@ internal class SimpleVideoEncoder( } fun encode(image: Bitmap) { - // it seems that Xiaomi devices have problems with hardware canvas, so we have to use - // lockCanvas instead https://stackoverflow.com/a/73520742 + /** it seems that Xiaomi devices have problems with hardware canvas, so we have to use + * lockCanvas instead https://stackoverflow.com/a/73520742 + * --- + * Same for Motorola devices. + * --- + * As for the T606, it's a Spreadtrum/Unisoc chipset and can be spread across various + * devices, so we have to check the SOC_MODEL property, as the manufacturer name might have + * changed. + * https://github.com/getsentry/sentry-android-gradle-plugin/issues/861#issuecomment-2867021256 + */ val canvas = if ( Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || - Build.MANUFACTURER.contains("motorola", ignoreCase = true) + Build.MANUFACTURER.contains("motorola", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MODEL).equals("T606", ignoreCase = true) ) { surface?.lockCanvas(null) } else { From 5546e1e1af87f703a72d2335433c0e6ec65dea32 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 29 Apr 2025 00:15:56 +0200 Subject: [PATCH 35/42] fix(replay): Use global visible rect when text layout is not laid out in Compose (#4361) * WIP * fix(replay): Use global visible rect when text layout is not laid out in Compose * Revert * Changelog --- CHANGELOG.md | 1 + .../viewhierarchy/ComposeViewHierarchyNode.kt | 12 ++++++--- .../ComposeMaskingOptionsTest.kt | 26 ++++++++++++++++--- .../android/compose/ComposeActivity.kt | 13 +++++++--- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9199fafb28..9b59927970e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) - Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) - Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) +- Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361)) ## 7.22.5 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index a4d82159da5..ce49e221e56 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.TextUnit import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryReplayOptions @@ -100,14 +101,19 @@ internal object ComposeViewHierarchyNode { ?.invoke(textLayoutResults) val (color, hasFillModifier) = node.findTextAttributes() - var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + val textLayoutResult = textLayoutResults.firstOrNull() + var textColor = textLayoutResult?.layoutInput?.style?.color if (textColor?.isUnspecified == true) { textColor = color } - // TODO: support multiple text layouts + val isLaidOut = textLayoutResult?.layoutInput?.style?.fontSize != TextUnit.Unspecified // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) TextViewHierarchyNode( - layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + layout = if (textLayoutResult != null && !isEditable && isLaidOut) { + ComposeTextLayout(textLayoutResult, hasFillModifier) + } else { + null + }, dominantColor = textColor?.toArgb()?.toOpaque(), x = visibleRect.left.toFloat(), y = visibleRect.top.toFloat(), diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 8fa3106058f..514d9ae4c35 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -18,7 +18,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.test.ext.junit.runners.AndroidJUnit4 import coil.compose.AsyncImage import io.sentry.SentryOptions @@ -39,6 +41,7 @@ import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -50,6 +53,7 @@ class ComposeMaskingOptionsTest { System.setProperty("robolectric.areWindowsMarkedVisible", "true") ComposeMaskingOptionsActivity.textModifierApplier = null ComposeMaskingOptionsActivity.containerModifierApplier = null + ComposeMaskingOptionsActivity.fontSizeApplier = null } @Test @@ -63,8 +67,23 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] assertTrue(textNodes.all { it.shouldMask }) - // just a sanity check for parsing the tree - assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + // no fontSize specified - we don't use the text layout + assertNull(textNodes.first().layout) + } + + @Test + fun `when text is laid out nodes use it`() { + ComposeMaskingOptionsActivity.fontSizeApplier = { 20.sp } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + val options = SentryOptions().apply { + sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + // the text should be laid out when fontSize is specified + assertEquals("Random repo", (textNodes.first().layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text) } @Test @@ -202,6 +221,7 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() { companion object { var textModifierApplier: (() -> Modifier)? = null var containerModifierApplier: (() -> Modifier)? = null + var fontSizeApplier: (() -> TextUnit)? = null } override fun onCreate(savedInstanceState: Bundle?) { @@ -221,11 +241,11 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() { contentDescription = null, modifier = Modifier.padding(vertical = 16.dp) ) + Text("Random repo", fontSize = fontSizeApplier?.invoke() ?: TextUnit.Unspecified) TextField( value = TextFieldValue("Placeholder"), onValueChange = { _ -> } ) - Text("Random repo") Button( onClick = {}, modifier = Modifier diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 3d2e670495d..e991be43b36 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -106,7 +106,11 @@ fun Github( val scope = rememberCoroutineScope() LaunchedEffect(perPage) { - result = GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + result = try { + GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + } catch (e: Throwable) { + "error" + } } SentryTraced("github-$user") { @@ -133,12 +137,15 @@ fun Github( user = newText } ) - Text("Random repo $result") + Text("Random repo: $result") Button( onClick = { scope.launch { - result = + result = try { GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + } catch (e: Throwable) { + "error" + } } }, modifier = Modifier From 0eb04f5a6f84de4c2eba78c08a9bf73991bad484 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 29 Apr 2025 00:24:06 +0200 Subject: [PATCH 36/42] fix(replay): Mask read-only TextField Composables (#4362) Newer versions of Compose seem to skip adding a SetText semantic entirely when a TextField is readOnly = true or enabled = false, so we fallback to the EditableText semantic which seems to be always present. --- CHANGELOG.md | 1 + .../viewhierarchy/ComposeViewHierarchyNode.kt | 6 +++-- .../ComposeMaskingOptionsTest.kt | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b59927970e..4a1d73fcadd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) - Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) - Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361)) +- Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362)) ## 7.22.5 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index ce49e221e56..ff4e79baeb0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -41,7 +41,8 @@ internal object ComposeViewHierarchyNode { return when { isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME collapsedSemantics?.contains(SemanticsProperties.Text) == true || - collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + collapsedSemantics?.contains(SemanticsActions.SetText) == true || + collapsedSemantics?.contains(SemanticsProperties.EditableText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME else -> "android.view.View" } } @@ -87,7 +88,8 @@ internal object ComposeViewHierarchyNode { val isVisible = !node.outerCoordinator.isTransparent() && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 - val isEditable = semantics?.contains(SemanticsActions.SetText) == true + val isEditable = semantics?.contains(SemanticsActions.SetText) == true || + semantics?.contains(SemanticsProperties.EditableText) == true return when { semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { val shouldMask = isVisible && node.shouldMask(isImage = false, options) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 514d9ae4c35..a5bab6550fc 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -15,8 +15,11 @@ import androidx.compose.material3.TextField import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.editableText import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -52,6 +55,7 @@ class ComposeMaskingOptionsTest { fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") ComposeMaskingOptionsActivity.textModifierApplier = null + ComposeMaskingOptionsActivity.textFieldModifierApplier = null ComposeMaskingOptionsActivity.containerModifierApplier = null ComposeMaskingOptionsActivity.fontSizeApplier = null } @@ -86,6 +90,23 @@ class ComposeMaskingOptionsTest { assertEquals("Random repo", (textNodes.first().layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text) } + @Test + fun `when text input field is readOnly still masks it`() { + ComposeMaskingOptionsActivity.textFieldModifierApplier = { + // newer versions of compose basically do this when a TextField is readOnly + Modifier.clearAndSetSemantics { editableText = AnnotatedString("Placeholder") } + } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + val options = SentryOptions().apply { + sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertTrue(textNodes[1].shouldMask) + } + @Test fun `when maskAllText is set to false all Text nodes are unmasked`() { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() @@ -220,6 +241,7 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() { companion object { var textModifierApplier: (() -> Modifier)? = null + var textFieldModifierApplier: (() -> Modifier)? = null var containerModifierApplier: (() -> Modifier)? = null var fontSizeApplier: (() -> TextUnit)? = null } @@ -243,6 +265,7 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() { ) Text("Random repo", fontSize = fontSizeApplier?.invoke() ?: TextUnit.Unspecified) TextField( + modifier = textFieldModifierApplier?.invoke() ?: Modifier, value = TextFieldValue("Placeholder"), onValueChange = { _ -> } ) From 307b0575e1f74ab72cd4192ae7e61617987a4f26 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Jun 2025 11:10:20 +0200 Subject: [PATCH 37/42] Determine recording size based on active window (#4354) * Determine recording size based on active window * Extend sample app with Dialog * Update Changelog * Use onPreDrawListener instead of onDrawListener for determining window size * fix(replay): Fix window tracking (#4419) * fix(replay): Fix window tracking * api dump * Fix Changelog * Fix tests * [SR] Remove configuration from start() method (#4454) * Remove configuration from start() method * Open up onConfigurationChanged for Hybrid SDKs * Address logging concerns * Format code * Cache last known config * Update sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt Co-authored-by: Roman Zavarnitsyn * Fix order * Fix compile issue --------- Co-authored-by: Sentry Github Bot Co-authored-by: Roman Zavarnitsyn --------- Co-authored-by: Roman Zavarnitsyn Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 1 + .../api/sentry-android-replay.api | 136 +- .../java/io/sentry/android/replay/Recorder.kt | 6 +- .../android/replay/ReplayIntegration.kt | 85 +- .../android/replay/ScreenshotRecorder.kt | 39 +- .../sentry/android/replay/WindowRecorder.kt | 68 +- .../replay/capture/BaseCaptureStrategy.kt | 23 +- .../replay/capture/BufferCaptureStrategy.kt | 23 +- .../android/replay/capture/CaptureStrategy.kt | 1 - .../replay/capture/SessionCaptureStrategy.kt | 42 +- .../io/sentry/android/replay/util/Views.kt | 30 +- .../android/replay/ReplayIntegrationTest.kt | 29 +- .../ReplayIntegrationWithRecorderTest.kt | 50 +- .../sentry/android/replay/ReplaySmokeTest.kt | 1 - .../capture/BufferCaptureStrategyTest.kt | 34 +- .../capture/SessionCaptureStrategyTest.kt | 41 +- .../sentry/samples/android/MainActivity.java | 14 + .../android/compose/ComposeActivity.kt | 52 + .../src/main/res/layout/activity_main.xml | 8 +- .../src/main/res/values/strings.xml | 1 + sentry/api/sentry.api | 1840 +++++++++++++---- .../java/io/sentry/SentryReplayOptions.java | 17 +- 22 files changed, 1830 insertions(+), 711 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1d73fcadd..a5a3d0b84cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) - Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361)) - Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362)) +- Correctly capture Dialogs and non full-sized windows ([#4354](https://github.com/getsentry/sentry-java/pull/4354)) ## 7.22.5 diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 33043e69b66..5d6df28f7b3 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -34,49 +34,47 @@ public final class io/sentry/android/replay/ModifierExtensionsKt { } public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + public abstract fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public abstract fun pause ()V + public abstract fun reset ()V public abstract fun resume ()V - public abstract fun start (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public abstract fun start ()V public abstract fun stop ()V } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public static final field $stable I - public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V public static synthetic fun addFrame$default (Lio/sentry/android/replay/ReplayCache;Ljava/io/File;JLjava/lang/String;ILjava/lang/Object;)V public fun close ()V public final fun createVideoOf (JJIIIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; - public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V - public final fun rotate (J)Ljava/lang/String; } -public final class io/sentry/android/replay/ReplayCache$Companion { - public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; -} - -public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/WindowCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V - public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V + public fun disableDebugMaskingOverlay ()V + public fun enableDebugMaskingOverlay ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z - public fun onConfigurationChanged (Landroid/content/res/Configuration;)V + public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V - public fun onLowMemory ()V public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public fun onScreenshotRecorded (Ljava/io/File;J)V public fun onTouchEvent (Landroid/view/MotionEvent;)V + public fun onWindowSizeChanged (II)V public fun pause ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V @@ -90,7 +88,6 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb public final class io/sentry/android/replay/ScreenshotRecorderConfig { public static final field $stable I - public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; public fun (IIFFII)V public final fun component1 ()I public final fun component2 ()I @@ -111,10 +108,6 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig { public fun toString ()Ljava/lang/String; } -public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { - public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; -} - public final class io/sentry/android/replay/SentryReplayModifiers { public static final field $stable I public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; @@ -133,36 +126,14 @@ public final class io/sentry/android/replay/ViewExtensionsKt { public static final fun sentryReplayUnmask (Landroid/view/View;)V } -public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { - public static final field $stable I - public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V - public fun onRootViewsChanged (Landroid/view/View;Z)V - public final fun stop ()V -} - -public final class io/sentry/android/replay/gestures/ReplayGestureConverter { - public static final field $stable I - public fun (Lio/sentry/transport/ICurrentDateProvider;)V - public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; +public abstract interface class io/sentry/android/replay/WindowCallback { + public abstract fun onWindowSizeChanged (II)V } public abstract interface class io/sentry/android/replay/gestures/TouchRecorderCallback { public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } -public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { - public static final field $stable I - public fun (Landroid/text/Layout;)V - public fun getDominantTextColor ()Ljava/lang/Integer; - public fun getEllipsisCount (I)I - public fun getLineBottom (I)I - public fun getLineCount ()I - public fun getLineStart (I)I - public fun getLineTop (I)I - public fun getLineVisibleEnd (I)I - public fun getPrimaryHorizontal (II)F -} - public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { public final field delegate Landroid/view/Window$Callback; public fun (Landroid/view/Window$Callback;)V @@ -193,82 +164,3 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } -public abstract interface class io/sentry/android/replay/util/TextLayout { - public abstract fun getDominantTextColor ()Ljava/lang/Integer; - public abstract fun getEllipsisCount (I)I - public abstract fun getLineBottom (I)I - public abstract fun getLineCount ()I - public abstract fun getLineStart (I)I - public abstract fun getLineTop (I)I - public abstract fun getLineVisibleEnd (I)I - public abstract fun getPrimaryHorizontal (II)F -} - -public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { - public abstract fun getVideoTime ()J - public abstract fun isStarted ()Z - public abstract fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V - public abstract fun release ()V - public abstract fun start (Landroid/media/MediaFormat;)V -} - -public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { - public static final field $stable I - public fun (Ljava/lang/String;F)V - public fun getVideoTime ()J - public fun isStarted ()Z - public fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V - public fun release ()V - public fun start (Landroid/media/MediaFormat;)V -} - -public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public static final field $stable I - public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; - public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getChildren ()Ljava/util/List; - public final fun getDistance ()I - public final fun getElevation ()F - public final fun getHeight ()I - public final fun getParent ()Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public final fun getShouldMask ()Z - public final fun getVisibleRect ()Landroid/graphics/Rect; - public final fun getWidth ()I - public final fun getX ()F - public final fun getY ()F - public final fun isImportantForContentCapture ()Z - public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z - public final fun isVisible ()Z - public final fun setChildren (Ljava/util/List;)V - public final fun setImportantForCaptureToAncestors (Z)V - public final fun setImportantForContentCapture (Z)V - public final fun traverse (Lkotlin/jvm/functions/Function1;)V -} - -public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { - public final fun fromView (Landroid/view/View;Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ILio/sentry/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; -} - -public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public static final field $stable I - public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public static final field $stable I - public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public static final field $stable I - public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getDominantColor ()Ljava/lang/Integer; - public final fun getLayout ()Lio/sentry/android/replay/util/TextLayout; - public final fun getPaddingLeft ()I - public final fun getPaddingTop ()I -} - diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt index 6cf86b6a7e6..15cdf5b03b2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt @@ -8,11 +8,15 @@ interface Recorder : Closeable { * at which the screenshots should be taken, and the screenshots size/resolution, which can * change e.g. in the case of orientation change or window size change */ - fun start(recorderConfig: ScreenshotRecorderConfig) + fun start() + + fun onConfigurationChanged(config: ScreenshotRecorderConfig) fun resume() fun pause() + fun reset() + fun stop() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 8c572c003c1..a23ab2b9e0d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -1,8 +1,6 @@ package io.sentry.android.replay -import android.content.ComponentCallbacks import android.content.Context -import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import android.view.MotionEvent @@ -59,23 +57,21 @@ public class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider, private val recorderProvider: (() -> Recorder)? = null, - private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, - ComponentCallbacks, IConnectionStatusObserver, - IRateLimitObserver { + IRateLimitObserver, + WindowCallback { // needed for the Java's call site constructor(context: Context, dateProvider: ICurrentDateProvider) : this( context.appContext(), dateProvider, null, - null, null ) @@ -83,12 +79,11 @@ public class ReplayIntegration( context: Context, dateProvider: ICurrentDateProvider, recorderProvider: (() -> Recorder)?, - recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)?, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null - ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + ) : this(context.appContext(), dateProvider, recorderProvider, replayCacheProvider) { this.replayCaptureStrategyProvider = replayCaptureStrategyProvider this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() this.gestureRecorderProvider = gestureRecorderProvider @@ -130,23 +125,12 @@ public class ReplayIntegration( } this.hub = hub - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler, replayExecutor) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) options.connectionStatusProvider.addConnectionStatusObserver(this) hub.rateLimiter?.addRateLimitObserver(this) - if (options.sessionReplay.isTrackOrientationChange) { - try { - context.registerComponentCallbacks(this) - } catch (e: Throwable) { - options.logger.log( - INFO, - "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", - e - ) - } - } addIntegrationToSdkVersion("Replay") SentryIntegrationPackageStorage.getInstance() @@ -177,17 +161,16 @@ public class ReplayIntegration( return } - val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) + lifecycle.currentState = STARTED captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, hub, dateProvider, replayExecutor, replayCacheProvider) } else { BufferCaptureStrategy(options, hub, dateProvider, random, replayExecutor, replayCacheProvider) } + recorder?.start() + captureStrategy?.start() - captureStrategy?.start(recorderConfig) - recorder?.start(recorderConfig) registerRootViewListeners() - lifecycle.currentState = STARTED } override fun resume() { @@ -208,9 +191,9 @@ public class ReplayIntegration( return } + lifecycle.currentState = RESUMED captureStrategy?.resume() recorder?.resume() - lifecycle.currentState = RESUMED } @Synchronized @@ -262,6 +245,7 @@ public class ReplayIntegration( } unregisterRootViewListeners() + recorder?.reset() recorder?.stop() gestureRecorder?.stop() captureStrategy?.stop() @@ -293,12 +277,7 @@ public class ReplayIntegration( options.connectionStatusProvider.removeConnectionStatusObserver(this) hub?.rateLimiter?.removeRateLimitObserver(this) - if (options.sessionReplay.isTrackOrientationChange) { - try { - context.unregisterComponentCallbacks(this) - } catch (ignored: Throwable) { - } - } + stop() recorder?.close() recorder = null @@ -307,24 +286,6 @@ public class ReplayIntegration( lifecycle.currentState = CLOSED } - override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get() || !isRecording()) { - return - } - - recorder?.stop() - - // refresh config based on new device configuration - val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) - captureStrategy?.onConfigurationChanged(recorderConfig) - - recorder?.start(recorderConfig) - // we have to restart recorder with a new config and pause immediately if the replay is paused - if (lifecycle.currentState == PAUSED) { - recorder?.pause() - } - } - override fun onConnectionStatusChanged(status: ConnectionStatus) { if (captureStrategy !is SessionCaptureStrategy) { // we only want to stop recording when offline for session mode @@ -352,8 +313,6 @@ public class ReplayIntegration( } } - override fun onLowMemory() = Unit - override fun onTouchEvent(event: MotionEvent) { if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) { return @@ -454,6 +413,30 @@ public class ReplayIntegration( } } + override fun onWindowSizeChanged(width: Int, height: Int) { + if (!isEnabled.get() || !isRecording()) { + return + } + if (options.sessionReplay.isTrackConfiguration) { + val recorderConfig = + ScreenshotRecorderConfig.fromSize(context, options.sessionReplay, width, height) + onConfigurationChanged(recorderConfig) + } + } + + public fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.onConfigurationChanged(config) + recorder?.onConfigurationChanged(config) + + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.pause() + } + } + private class PreviousReplayHint : Backfillable { override fun shouldEnrich(): Boolean = false } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 98e8d12c743..7b0c275f747 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -7,15 +7,11 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix import android.graphics.Paint -import android.graphics.Point import android.graphics.Rect import android.graphics.RectF -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import android.view.PixelCopy import android.view.View import android.view.ViewTreeObserver -import android.view.WindowManager import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryLevel.WARNING @@ -178,6 +174,9 @@ internal class ScreenshotRecorder( } override fun onDraw() { + if (!isCapturing.get()) { + return + } val root = rootView?.get() if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") @@ -290,35 +289,26 @@ public data class ScreenshotRecorderConfig( } } - fun from( + fun fromSize( context: Context, - sessionReplay: SentryReplayOptions + sessionReplay: SentryReplayOptions, + windowWidth: Int, + windowHeight: Int ): ScreenshotRecorderConfig { - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { - wm.currentWindowMetrics.bounds - } else { - val screenBounds = Point() - @Suppress("DEPRECATION") - wm.defaultDisplay.getRealSize(screenBounds) - Rect(0, 0, screenBounds.x, screenBounds.y) - } - // use the baseline density of 1x (mdpi) val (height, width) = - ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + ((windowHeight / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() to - ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + ((windowWidth / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() return ScreenshotRecorderConfig( recordingWidth = width, recordingHeight = height, - scaleFactorX = width.toFloat() / screenBounds.width(), - scaleFactorY = height.toFloat() / screenBounds.height(), + scaleFactorX = width.toFloat() / windowWidth, + scaleFactorY = height.toFloat() / windowHeight, frameRate = sessionReplay.frameRate, bitRate = sessionReplay.quality.bitRate ) @@ -347,3 +337,10 @@ public interface ScreenshotRecorderCallback { */ fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) } + +/** + * A callback to be invoked when once current window size is determined or changes + */ +public interface WindowCallback { + public fun onWindowSizeChanged(width: Int, height: Int) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 8f4b0526fcb..05762acc34b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,10 +1,15 @@ package io.sentry.android.replay import android.annotation.TargetApi +import android.graphics.Point import android.view.View +import android.view.ViewTreeObserver import io.sentry.SentryOptions import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.addOnPreDrawListenerSafe import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.hasSize +import io.sentry.android.replay.util.removeOnPreDrawListenerSafe import io.sentry.android.replay.util.scheduleAtFixedRateSafely import java.lang.ref.WeakReference import java.util.concurrent.Executors @@ -18,6 +23,7 @@ import java.util.concurrent.atomic.AtomicBoolean internal class WindowRecorder( private val options: SentryOptions, private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService ) : Recorder, OnRootViewsChangedListener { @@ -28,6 +34,7 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() + private var lastKnownWindowSize: Point = Point() private val rootViewsLock = Any() private var recorder: ScreenshotRecorder? = null private var capturingTask: ScheduledFuture<*>? = null @@ -40,6 +47,7 @@ internal class WindowRecorder( if (added) { rootViews.add(WeakReference(root)) recorder?.bind(root) + determineWindowSize(root) } else { recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -47,6 +55,7 @@ internal class WindowRecorder( val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null && root != newRoot) { recorder?.bind(newRoot) + determineWindowSize(newRoot) } else { Unit // synchronized block wants us to return something lol } @@ -54,19 +63,62 @@ internal class WindowRecorder( } } - override fun start(recorderConfig: ScreenshotRecorderConfig) { - if (isRecording.getAndSet(true)) { + fun determineWindowSize(root: View) { + if (root.hasSize()) { + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + } else { + root.addOnPreDrawListenerSafe(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val currentRoot = rootViews.lastOrNull()?.get() + // in case the root changed in the meantime, ignore the preDraw of the outdate root + if (root != currentRoot) { + root.removeOnPreDrawListenerSafe(this) + return true + } + if (root.hasSize()) { + root.removeOnPreDrawListenerSafe(this) + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + } + return true + } + }) + } + } + + override fun start() { + isRecording.getAndSet(true) + } + + override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + if (!isRecording.get()) { return } - recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) + recorder = ScreenshotRecorder( + config, + options, + mainLooperHandler, + replayExecutor, + screenshotRecorderCallback + ) + + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null) { + recorder?.bind(newRoot) + } // TODO: change this to use MainThreadHandler and just post on the main thread with delay // to avoid thread context switch every time capturingTask = capturer.scheduleAtFixedRateSafely( options, "$TAG.capture", 100L, // delay the first run by a bit, to allow root view listener to register - 1000L / recorderConfig.frameRate, + 1000L / config.frameRate, MILLISECONDS ) { recorder?.capture() @@ -76,15 +128,20 @@ internal class WindowRecorder( override fun resume() { recorder?.resume() } + override fun pause() { recorder?.pause() } - override fun stop() { + override fun reset() { + lastKnownWindowSize.set(0, 0) synchronized(rootViewsLock) { rootViews.forEach { recorder?.unbind(it.get()) } rootViews.clear() } + } + + override fun stop() { recorder?.close() recorder = null capturingTask?.cancel(false) @@ -93,6 +150,7 @@ internal class WindowRecorder( } override fun close() { + reset() stop() capturer.gracefullyShutdown(options) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6dd7906d305..19a926b7682 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -61,10 +61,10 @@ internal abstract class BaseCaptureStrategy( protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + protected var recorderConfig: ScreenshotRecorderConfig? by persistableAtomicNullable(propertyName = "") { _, _, newValue -> if (newValue == null) { // recorderConfig is only nullable on init, but never after - return@persistableAtomic + return@persistableAtomicNullable } cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) @@ -85,7 +85,6 @@ internal abstract class BaseCaptureStrategy( protected val currentEvents: Deque = ConcurrentLinkedDeque() override fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, replayType: ReplayType? @@ -95,7 +94,6 @@ internal abstract class BaseCaptureStrategy( this.currentReplayId = replayId this.currentSegment = segmentId this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER) - this.recorderConfig = recorderConfig segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -121,10 +119,10 @@ internal abstract class BaseCaptureStrategy( segmentId: Int, height: Int, width: Int, + frameRate: Int, + bitRate: Int, replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, - frameRate: Int = recorderConfig.frameRate, - bitRate: Int = recorderConfig.bitRate, screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: Deque = this.currentEvents @@ -152,9 +150,11 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - val rrwebEvents = gestureConverter.convert(event, recorderConfig) - if (rrwebEvents != null) { - currentEvents += rrwebEvents + recorderConfig?.let { config -> + val rrwebEvents = gestureConverter.convert(event, config) + if (rrwebEvents != null) { + currentEvents += rrwebEvents + } } } @@ -209,9 +209,4 @@ internal abstract class BaseCaptureStrategy( } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty - - private inline fun persistableAtomic( - crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit - ): ReadWriteProperty = - persistableAtomicNullable(null, "", onChange) as ReadWriteProperty } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 79bebf1d88e..298312e7e22 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -132,7 +132,7 @@ internal class BufferCaptureStrategy( } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) - captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) + captureStrategy.start(segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) return captureStrategy } @@ -190,6 +190,14 @@ internal class BufferCaptureStrategy( } private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val currentConfig = recorderConfig + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not creating segment for task: $taskName" + ) + return + } val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { @@ -200,12 +208,19 @@ internal class BufferCaptureStrategy( } val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - val height = this.recorderConfig.recordingHeight - val width = this.recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, currentSegment, height, width) + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate + ) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 6efaf47e3cb..c09e4b67082 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -31,7 +31,6 @@ internal interface CaptureStrategy { var segmentTimestamp: Date? fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index f28bd9f9c04..06fa13bf003 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -29,12 +29,11 @@ internal class SessionCaptureStrategy( } override fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, replayType: ReplayType? ) { - super.start(recorderConfig, segmentId, replayId, replayType) + super.start(segmentId, replayId, replayType) // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { @@ -75,9 +74,8 @@ internal class SessionCaptureStrategy( override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured + val currentConfig = recorderConfig val frameTimestamp = dateProvider.currentTimeMillis - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.store(frameTimestamp) @@ -92,6 +90,14 @@ internal class SessionCaptureStrategy( return@submitSafely } + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not recording frame" + ) + return@submitSafely + } + val now = dateProvider.currentTimeMillis if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { val segment = @@ -100,8 +106,10 @@ internal class SessionCaptureStrategy( currentSegmentTimestamp, currentReplayId, currentSegment, - height, - width + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate ) if (segment is ReplaySegment.Created) { segment.capture(hub) @@ -136,15 +144,31 @@ internal class SessionCaptureStrategy( override fun convert(): CaptureStrategy = this private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val currentConfig = recorderConfig + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not creating segment for task: $taskName" + ) + return + } + val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp ?: return val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, currentSegment, height, width) + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate + ) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index b51f2f98475..cd3fe1610c4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -186,7 +186,7 @@ internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListen } try { viewTreeObserver.addOnDrawListener(listener) - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { // viewTreeObserver is already dead } } @@ -197,7 +197,33 @@ internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawLis } try { viewTreeObserver.removeOnDrawListener(listener) - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { // viewTreeObserver is already dead } } + +internal fun View?.addOnPreDrawListenerSafe(listener: ViewTreeObserver.OnPreDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + try { + viewTreeObserver.addOnPreDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } +} + +internal fun View?.removeOnPreDrawListenerSafe(listener: ViewTreeObserver.OnPreDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + try { + viewTreeObserver.removeOnPreDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } +} + +internal fun View.hasSize(): Boolean { + return width != 0 && height != 0 +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 93632d2df7d..862db6d6244 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -110,7 +110,6 @@ class ReplayIntegrationTest { isRateLimited: Boolean = false, recorderProvider: (() -> Recorder)? = null, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, - recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { @@ -128,7 +127,6 @@ class ReplayIntegrationTest { context, dateProvider, recorderProvider, - recorderConfigProvider = recorderConfigProvider, replayCacheProvider = { _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider, gestureRecorderProvider = gestureRecorderProvider @@ -186,7 +184,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start(any(), any(), any(), anyOrNull()) + verify(captureStrategy, never()).start(any(), any(), anyOrNull()) } @Test @@ -210,7 +208,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, times(1)).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -226,7 +223,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, never()).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -242,7 +238,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, times(1)).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -257,7 +252,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(recorder).start(any()) + verify(recorder).start() } @Test @@ -432,25 +427,21 @@ class ReplayIntegrationTest { @Test fun `onConfigurationChanged stops and restarts recorder with a new recorder config`() { - var configChanged = false val recorderConfig = mock() val captureStrategy = mock() val recorder = mock() val replay = fixture.getSut( context, recorderProvider = { recorder }, - replayCaptureStrategyProvider = { captureStrategy }, - recorderConfigProvider = { configChanged = it; recorderConfig } + replayCaptureStrategyProvider = { captureStrategy } ) replay.register(fixture.hub, fixture.options) replay.start() - replay.onConfigurationChanged(mock()) + replay.onConfigurationChanged(recorderConfig) - verify(recorder).stop() verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) - verify(recorder, times(2)).start(eq(recorderConfig)) - assertTrue(configChanged) + verify(recorder, times(1)).start() } @Test @@ -710,27 +701,23 @@ class ReplayIntegrationTest { @Test fun `if recording is paused in configChanges re-pauses it again`() { - var configChanged = false val recorderConfig = mock() val captureStrategy = mock() val recorder = mock() val replay = fixture.getSut( context, recorderProvider = { recorder }, - replayCaptureStrategyProvider = { captureStrategy }, - recorderConfigProvider = { configChanged = it; recorderConfig } + replayCaptureStrategyProvider = { captureStrategy } ) replay.register(fixture.hub, fixture.options) replay.start() replay.pause() - replay.onConfigurationChanged(mock()) + replay.onConfigurationChanged(recorderConfig) - verify(recorder).stop() verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) - verify(recorder, times(2)).start(eq(recorderConfig)) + verify(recorder, times(1)).start() verify(recorder, times(2)).pause() - assertTrue(configChanged) } @Test diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index e2491d3796a..ec8c4eceea7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -57,14 +57,12 @@ class ReplayIntegrationWithRecorderTest { fun getSut( context: Context, recorder: Recorder, - recorderConfig: ScreenshotRecorderConfig, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, - recorderProvider = { recorder }, - recorderConfigProvider = { recorderConfig } + recorderProvider = { recorder } ) } } @@ -90,18 +88,22 @@ class ReplayIntegrationWithRecorderTest { System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration } + fixture.options.sessionReplay.isTrackConfiguration = false fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) val recorder = object : Recorder { var state: LifecycleState = INITALIZED - override fun start(recorderConfig: ScreenshotRecorderConfig) { + override fun start() { state = STARTED } + override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + // no-op + } + override fun resume() { state = RESUMED } @@ -110,6 +112,10 @@ class ReplayIntegrationWithRecorderTest { state = PAUSED } + override fun reset() { + state = STOPPED + } + override fun stop() { state = STOPPED } @@ -119,12 +125,23 @@ class ReplayIntegrationWithRecorderTest { } } - replay = fixture.getSut(context, recorder, recorderConfig, dateProvider) + replay = fixture.getSut(context, recorder, dateProvider) replay.register(fixture.hub, fixture.options) assertEquals(INITALIZED, recorder.state) replay.start() + + // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed + // inside recorder.start() + val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + replay.onWindowSizeChanged(640, 480) assertEquals(STARTED, recorder.state) replay.pause() @@ -133,20 +150,19 @@ class ReplayIntegrationWithRecorderTest { replay.resume() assertEquals(RESUMED, recorder.state) + // this should be ignored, as no manual onConfigurationChanged was called so far + replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) + replay.stop() assertEquals(STOPPED, recorder.state) // start again and capture some frames replay.start() - // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed - // inside recorder.start() - val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + // E.g. Flutter will trigger onConfigurationChanged + val flutterConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + replay.onConfigurationChanged(flutterConfig) - screenshot.outputStream().use { - Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) - it.flush() - } replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) // verify @@ -161,12 +177,12 @@ class ReplayIntegrationWithRecorderTest { }, check { val metaEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(200, metaEvents?.first()?.height) - assertEquals(100, metaEvents?.first()?.width) + assertEquals(flutterConfig.recordingHeight, metaEvents?.first()?.height) + assertEquals(flutterConfig.recordingWidth, metaEvents?.first()?.width) val videoEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(200, videoEvents?.first()?.height) - assertEquals(100, videoEvents?.first()?.width) + assertEquals(flutterConfig.recordingHeight, videoEvents?.first()?.height) + assertEquals(flutterConfig.recordingWidth, videoEvents?.first()?.width) assertEquals(5000, videoEvents?.first()?.durationMs) assertEquals(5, videoEvents?.first()?.frameCount) assertEquals(1, videoEvents?.first()?.frameRate) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 9bd8e2038dc..136d568c403 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -78,7 +78,6 @@ class ReplaySmokeTest { context, dateProvider, recorderProvider = null, - recorderConfigProvider = null, replayCaptureStrategyProvider = null, replayCacheProvider = null, mainLooperHandler = mock { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 29c777e171f..62b30d09f0e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -123,7 +123,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -135,7 +135,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -149,7 +149,8 @@ class BufferCaptureStrategyTest { @Test fun `pause creates but does not capture current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId()) + strategy.start(0, SentryId()) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.pause() @@ -166,7 +167,7 @@ class BufferCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) strategy.stop() @@ -185,7 +186,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -199,7 +200,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -210,7 +211,8 @@ class BufferCaptureStrategyTest { @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) strategy.onConfigurationChanged(newConfig) @@ -224,7 +226,7 @@ class BufferCaptureStrategyTest { @Test fun `convert does nothing when process is terminating`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(true) {} @@ -235,7 +237,7 @@ class BufferCaptureStrategyTest { @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() val converted = strategy.convert() assertTrue(converted is SessionCaptureStrategy) @@ -245,7 +247,7 @@ class BufferCaptureStrategyTest { @Test fun `convert persists buffer replayType when converting to session strategy`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() val converted = strategy.convert() assertEquals( @@ -257,7 +259,7 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay does not replayId to scope when not sampled`() { val strategy = fixture.getSut(onErrorSampleRate = 0.0) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(false) {} @@ -268,7 +270,9 @@ class BufferCaptureStrategyTest { fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() strategy.captureReplay(false) { @@ -284,7 +288,9 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay sets new segment timestamp to new strategy after successful creation`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val oldTimestamp = strategy.segmentTimestamp strategy.captureReplay(false) { newTimestamp -> @@ -299,7 +305,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals( replayId.toString(), diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index c0fd4f2d043..f5cc1a24894 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -122,7 +122,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals(replayId, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -134,7 +134,8 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -164,7 +165,8 @@ class SessionCaptureStrategyTest { @Test fun `pause creates and captures current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId()) + strategy.start(0, SentryId()) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.pause() @@ -184,7 +186,8 @@ class SessionCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.stop() @@ -204,7 +207,7 @@ class SessionCaptureStrategyTest { @Test fun `captureReplay does nothing for non-crashed event`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(false) {} @@ -218,7 +221,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(true) {} strategy.onScreenshotRecorded(mock()) {} @@ -233,7 +236,8 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -272,7 +276,8 @@ class SessionCaptureStrategyTest { } } ) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(mock()) strategy.onScreenshotRecorded(mock()) {} @@ -282,7 +287,8 @@ class SessionCaptureStrategyTest { @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) strategy.onConfigurationChanged(newConfig) @@ -317,7 +323,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) @@ -341,7 +348,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) @@ -367,7 +375,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} @@ -388,7 +397,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals( replayId.toString(), @@ -408,7 +417,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} @@ -452,7 +462,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} verify(fixture.hub).captureReplay( diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 33dd35f9867..78416f8db34 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -3,6 +3,7 @@ import android.content.Intent; import android.os.Bundle; import android.os.Handler; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Attachment; import io.sentry.ISpan; @@ -257,6 +258,19 @@ public void run() { binding.openMetrics.setOnClickListener( view -> startActivity(new Intent(this, MetricsActivity.class))); + binding.showDialog.setOnClickListener( + view -> { + new AlertDialog.Builder(MainActivity.this) + .setTitle("Example Title") + .setMessage("Example Message") + .setPositiveButton( + "Close", + (dialog, which) -> { + dialog.dismiss(); + }) + .show(); + }); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index e991be43b36..7a699a94f33 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -8,9 +8,18 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -55,11 +64,14 @@ class ComposeActivity : ComponentActivity() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Landing( navigateGithub: () -> Unit, navigateGithubWithArgs: () -> Unit ) { + var showDialog by remember { mutableStateOf(false) } + SentryTraced(tag = "buttons_page") { Column( verticalArrangement = Arrangement.Center, @@ -92,6 +104,46 @@ fun Landing( Text("Crash from Compose") } } + SentryTraced(tag = "button_dialog") { + Button( + onClick = { + showDialog = true + }, + modifier = Modifier + .testTag("button_show_dialog") + .padding(top = 32.dp) + ) { + Text("Show Dialog", modifier = Modifier.sentryReplayUnmask()) + } + } + if (showDialog) { + BasicAlertDialog( + onDismissRequest = { + showDialog = false + }, + content = { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(20.dp), + content = { + Text( + "Dialog Title", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.size(20.dp)) + Text("Dialog Content") + } + ) + } + } + ) + } } } } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 620acaa04cf..506f11f7ffb 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -143,10 +143,16 @@ android:text="@string/open_frame_data_for_spans"/>