From f6b464b1f89f654648684093ffd93ae862557c61 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Apr 2024 16:03:36 +0200 Subject: [PATCH 01/10] Capture motion events as incremental rrweb events --- .../android/replay/ReplayIntegration.kt | 9 +- .../sentry/android/replay/WindowRecorder.kt | 73 ++++- .../replay/capture/BaseCaptureStrategy.kt | 66 +++- .../android/replay/capture/CaptureStrategy.kt | 3 + .../replay/util/FixedWindowCallback.java | 235 ++++++++++++++ .../rrweb/RRWebIncrementalSnapshotEvent.java | 101 ++++++ .../sentry/rrweb/RRWebInteractionEvent.java | 254 +++++++++++++++ .../rrweb/RRWebInteractionMoveEvent.java | 288 ++++++++++++++++++ .../RRWebInteractionEventSerializationTest.kt | 40 +++ ...ebInteractionMoveEventSerializationTest.kt | 45 +++ .../json/rrweb_interaction_event.json | 12 + .../json/rrweb_interaction_move_event.json | 15 + 12 files changed, 1133 insertions(+), 8 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/rrweb_interaction_event.json create mode 100644 sentry/src/test/resources/json/rrweb_interaction_move_event.json 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 cc3248e1a4..b2d34b0862 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 @@ -5,6 +5,7 @@ import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build +import android.view.MotionEvent import io.sentry.Hint import io.sentry.IHub import io.sentry.Integration @@ -32,7 +33,7 @@ public class ReplayIntegration( private val recorderProvider: (() -> Recorder)? = null, private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null -) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { +) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, ComponentCallbacks { // needed for the Java's call site constructor(context: Context, dateProvider: ICurrentDateProvider) : this( @@ -72,7 +73,7 @@ public class ReplayIntegration( } this.hub = hub - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this) isEnabled.set(true) try { @@ -212,4 +213,8 @@ public class ReplayIntegration( } override fun onLowMemory() = Unit + + override fun onTouchEvent(event: MotionEvent) { + captureStrategy?.onTouchEvent(event) + } } 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 ceadfcf573..2ee60d7466 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,8 +1,13 @@ package io.sentry.android.replay import android.annotation.TargetApi +import android.view.MotionEvent import android.view.View +import android.view.Window +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions +import io.sentry.android.replay.util.FixedWindowCallback import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.scheduleAtFixedRateSafely import java.lang.ref.WeakReference @@ -16,7 +21,8 @@ import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, - private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val touchRecorderCallback: TouchRecorderCallback? = null ) : Recorder { internal companion object { @@ -39,7 +45,11 @@ internal class WindowRecorder( if (added) { rootViews.add(WeakReference(root)) recorder?.bind(root) + + root.startGestureTracking() } else { + root.stopGestureTracking() + recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -86,6 +96,60 @@ internal class WindowRecorder( isRecording.set(false) } + override fun close() { + stop() + capturer.gracefullyShutdown(options) + } + + private fun View.startGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not tracking gestures") + return + } + + if (touchRecorderCallback == null) { + options.logger.log(DEBUG, "TouchRecorderCallback is null, not tracking gestures") + return + } + + val delegate = window.callback + window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + } + + private fun View.stopGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window was null in stopGestureTracking") + return + } + + if (window.callback is SentryReplayGestureRecorder) { + val delegate = (window.callback as SentryReplayGestureRecorder).delegate + window.callback = delegate + } + } + + private class SentryReplayGestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback?, + delegate: Window.Callback? + ) : FixedWindowCallback(delegate) { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (event != null) { + val copy: MotionEvent = MotionEvent.obtain(event) + try { + touchRecorderCallback?.onTouchEvent(copy) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error dispatching touch event", e) + } finally { + copy.recycle() + } + } + return super.dispatchTouchEvent(event) + } + } + private class RecorderExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -94,9 +158,8 @@ internal class WindowRecorder( return ret } } +} - override fun close() { - stop() - capturer.gracefullyShutdown(options) - } +public interface TouchRecorderCallback { + fun onTouchEvent(event: MotionEvent) } 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 d390d75124..9b3516f330 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 @@ -1,10 +1,12 @@ package io.sentry.android.replay.capture +import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.ReplayRecording +import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType @@ -17,6 +19,11 @@ import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebSpanEvent import io.sentry.rrweb.RRWebVideoEvent @@ -42,6 +49,7 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" + private const val DEBOUNCE_TIMEOUT = 200 private val snakecasePattern = "_[a-z]".toRegex() private val supportedNetworkData = setOf( "status_code", @@ -59,6 +67,8 @@ internal abstract class BaseCaptureStrategy( override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir + private val currentEvents = mutableListOf() + private val lastExecutionTime = AtomicLong(0) protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -225,7 +235,7 @@ internal abstract class BaseCaptureStrategy( } breadcrumb.type == "system" -> { - breadcrumbCategory = breadcrumb.type!! + breadcrumbCategory = null breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" } @@ -248,6 +258,12 @@ internal abstract class BaseCaptureStrategy( } } } + currentEvents.removeAll { + if (it.timestamp > segmentTimestamp.time && it.timestamp < endTimestamp.time) { + recordingPayload += it + } + it.timestamp < endTimestamp.time + } val recording = ReplayRecording().apply { this.segmentId = segmentId @@ -265,6 +281,13 @@ internal abstract class BaseCaptureStrategy( this.recorderConfig = recorderConfig } + override fun onTouchEvent(event: MotionEvent) { + val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() + if (rrwebEvent != null) { + currentEvents += rrwebEvent + } + } + override fun close() { replayExecutor.gracefullyShutdown(options) } @@ -335,4 +358,45 @@ internal abstract class BaseCaptureStrategy( data = breadcrumbData } } + + private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): RRWebIncrementalSnapshotEvent? { + val event = this + return when(val action = event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.getCurrentTimeMillis() + if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { + return null + } + lastExecutionTime.set(now) + + RRWebInteractionMoveEvent().apply { + timestamp = dateProvider.currentTimeMillis + positions = listOf( + Position().apply { + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = event.getPointerId(event.actionIndex) + timeOffset = 0 // TODO: is this needed? + } + ) // TODO: support multiple pointers + } + } + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = event.getPointerId(event.actionIndex) + interactionType = when (action) { + MotionEvent.ACTION_UP -> InteractionType.TouchEnd + MotionEvent.ACTION_DOWN -> InteractionType.TouchStart + MotionEvent.ACTION_CANCEL -> InteractionType.TouchCancel + else -> InteractionType.TouchMove_Departed // should not happen + } + } + } + else -> null + } + } } 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 821ebcef66..357019dc73 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 @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.view.MotionEvent import io.sentry.Hint import io.sentry.SentryEvent import io.sentry.android.replay.ReplayCache @@ -28,6 +29,8 @@ internal interface CaptureStrategy { fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) + fun onTouchEvent(event: MotionEvent) + fun convert(): CaptureStrategy fun close() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java new file mode 100644 index 0000000000..d2121e8849 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java @@ -0,0 +1,235 @@ +/** + * Adapted from https://github.com/square/curtains/tree/v1.2.5 + * + * Copyright 2021 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.android.replay.util; + +import android.annotation.SuppressLint; +import android.view.ActionMode; +import android.view.KeyEvent; +import android.view.KeyboardShortcutGroup; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SearchEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Implementation of Window.Callback that updates the signature of + * {@link #onMenuOpened(int, Menu)} to change the menu param from + * non null to nullable to avoid runtime null check crashes. + * Issue: https://issuetracker.google.com/issues/188568911 + */ +public class FixedWindowCallback implements Window.Callback { + + public final @Nullable Window.Callback delegate; + + public FixedWindowCallback(@Nullable Window.Callback delegate) { + this.delegate = delegate; + } + + @Override public boolean dispatchKeyEvent(KeyEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchKeyEvent(event); + } + + @Override public boolean dispatchKeyShortcutEvent(KeyEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchKeyShortcutEvent(event); + } + + @Override public boolean dispatchTouchEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchTouchEvent(event); + } + + @Override public boolean dispatchTrackballEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchTrackballEvent(event); + } + + @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchGenericMotionEvent(event); + } + + @Override public boolean dispatchPopulateAccessibilityEvent( + AccessibilityEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchPopulateAccessibilityEvent(event); + } + + @Nullable @Override public View onCreatePanelView(int featureId) { + if (delegate == null) { + return null; + } + return delegate.onCreatePanelView(featureId); + } + + @Override public boolean onCreatePanelMenu(int featureId, @NotNull Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onCreatePanelMenu(featureId, menu); + } + + @Override public boolean onPreparePanel(int featureId, @Nullable View view, + @NotNull Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onPreparePanel(featureId, view, menu); + } + + @Override public boolean onMenuOpened(int featureId, @Nullable Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onMenuOpened(featureId, menu); + } + + @Override public boolean onMenuItemSelected(int featureId, + @NotNull MenuItem item) { + if (delegate == null) { + return false; + } + return delegate.onMenuItemSelected(featureId, item); + } + + @Override public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { + if (delegate == null) { + return; + } + delegate.onWindowAttributesChanged(attrs); + } + + @Override public void onContentChanged() { + if (delegate == null) { + return; + } + delegate.onContentChanged(); + } + + @Override public void onWindowFocusChanged(boolean hasFocus) { + if (delegate == null) { + return; + } + delegate.onWindowFocusChanged(hasFocus); + } + + @Override public void onAttachedToWindow() { + if (delegate == null) { + return; + } + delegate.onAttachedToWindow(); + } + + @Override public void onDetachedFromWindow() { + if (delegate == null) { + return; + } + delegate.onDetachedFromWindow(); + } + + @Override public void onPanelClosed(int featureId, @NotNull Menu menu) { + if (delegate == null) { + return; + } + delegate.onPanelClosed(featureId, menu); + } + + @Override public boolean onSearchRequested() { + if (delegate == null) { + return false; + } + return delegate.onSearchRequested(); + } + + @SuppressLint("NewApi") + @Override public boolean onSearchRequested(SearchEvent searchEvent) { + if (delegate == null) { + return false; + } + return delegate.onSearchRequested(searchEvent); + } + + @Nullable @Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + if (delegate == null) { + return null; + } + return delegate.onWindowStartingActionMode(callback); + } + + @SuppressLint("NewApi") + @Nullable @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, + int type) { + if (delegate == null) { + return null; + } + return delegate.onWindowStartingActionMode(callback, type); + } + + @Override public void onActionModeStarted(ActionMode mode) { + if (delegate == null) { + return; + } + delegate.onActionModeStarted(mode); + } + + @Override public void onActionModeFinished(ActionMode mode) { + if (delegate == null) { + return; + } + delegate.onActionModeFinished(mode); + } + + @SuppressLint("NewApi") + @Override public void onProvideKeyboardShortcuts(List data, + @Nullable Menu menu, int deviceId) { + if (delegate == null) { + return; + } + delegate.onProvideKeyboardShortcuts(data, menu, deviceId); + } + + @SuppressLint("NewApi") + @Override public void onPointerCaptureChanged(boolean hasCapture) { + if (delegate == null) { + return; + } + delegate.onPointerCaptureChanged(hasCapture); + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java new file mode 100644 index 0000000000..89b945504b --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -0,0 +1,101 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public abstract class RRWebIncrementalSnapshotEvent extends RRWebEvent { + + public enum IncrementalSource implements JsonSerializable { + Mutation, + MouseMove, + MouseInteraction, + Scroll, + ViewportResize, + Input, + TouchMove, + MediaInteraction, + StyleSheetRule, + CanvasMutation, + Font, + Log, + Drag, + StyleDeclaration, + Selection, + AdoptedStyleSheet, + CustomElement; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull IncrementalSource deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return IncrementalSource.values()[reader.nextInt()]; + } + } + } + + private IncrementalSource source; + + public RRWebIncrementalSnapshotEvent(final @NotNull IncrementalSource source) { + super(RRWebEventType.IncrementalSnapshot); + this.source = source; + } + + public IncrementalSource getSource() { + return source; + } + + public void setSource(final IncrementalSource source) { + this.source = source; + } + + // region json + public static final class JsonKeys { + public static final String SOURCE = "source"; + } + + public static final class Serializer { + public void serialize( + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull ObjectWriter writer, + final @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.SOURCE).value(logger, baseEvent.source); + } + } + + public static final class Deserializer { + public boolean deserializeValue( + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull String nextName, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + if (nextName.equals(JsonKeys.SOURCE)) { + baseEvent.source = + Objects.requireNonNull( + reader.nextOrNull(logger, new IncrementalSource.Deserializer()), ""); + return true; + } + return false; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java new file mode 100644 index 0000000000..e08feb52b2 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -0,0 +1,254 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("SameNameButDifferent") +public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent implements + JsonSerializable, JsonUnknown { + + public enum InteractionType implements JsonSerializable { + MouseUp, + MouseDown, + Click, + ContextMenu, + DblClick, + Focus, + Blur, + TouchStart, + TouchMove_Departed, + TouchEnd, + TouchCancel; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull InteractionType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return InteractionType.values()[reader.nextInt()]; + } + } + } + + + private static final int POINTER_TYPE_TOUCH = 2; + + private @Nullable InteractionType interactionType; + + private int id; + + private float x; + + private float y; + + private int pointerType = POINTER_TYPE_TOUCH; + + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebInteractionEvent() { + super(IncrementalSource.MouseInteraction); + } + + @Nullable + public InteractionType getInteractionType() { + return interactionType; + } + + public void setInteractionType(final @Nullable InteractionType type) { + this.interactionType = type; + } + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public float getX() { + return x; + } + + public void setX(final float x) { + this.x = x; + } + + public float getY() { + return y; + } + + public void setY(final float y) { + this.y = y; + } + + public int getPointerType() { + return pointerType; + } + + public void setPointerType(final int pointerType) { + this.pointerType = pointerType; + } + + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String TYPE = "type"; + public static final String ID = "id"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String POINTER_TYPE = "pointerType"; + } + + @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.TYPE).value(logger, interactionType); + writer.name(JsonKeys.ID).value(id); + writer.name(JsonKeys.X).value(x); + writer.name(JsonKeys.Y).value(y); + writer.name(JsonKeys.POINTER_TYPE).value(pointerType); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements + JsonDeserializer { + + @Override + public @NotNull RRWebInteractionEvent deserialize(@NotNull ObjectReader reader, + @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebInteractionEvent event = new RRWebInteractionEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebInteractionEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = new RRWebIncrementalSnapshotEvent.Deserializer(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + event.interactionType = reader.nextOrNull(logger, new InteractionType.Deserializer()); + break; + case JsonKeys.ID: + event.id = reader.nextInt(); + break; + case JsonKeys.X: + event.x = reader.nextFloat(); + break; + case JsonKeys.Y: + event.y = reader.nextFloat(); + break; + case JsonKeys.POINTER_TYPE: + event.pointerType = reader.nextInt(); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (dataUnknown == null) { + dataUnknown = new HashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + break; + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java new file mode 100644 index 0000000000..9edf7e836b --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -0,0 +1,288 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("SameNameButDifferent") +public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent implements + JsonSerializable, JsonUnknown { + + public static final class Position implements JsonSerializable, JsonUnknown { + + private int id; + + private float x; + + private float y; + + private long timeOffset; + + private @Nullable Map unknown; + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public float getX() { + return x; + } + + public void setX(final float x) { + this.x = x; + } + + public float getY() { + return y; + } + + public void setY(final float y) { + this.y = y; + } + + public long getTimeOffset() { + return timeOffset; + } + + public void setTimeOffset(final long timeOffset) { + this.timeOffset = timeOffset; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String ID = "id"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String TIME_OFFSET = "timeOffset"; + } + + @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.ID).value(id); + writer.name(JsonKeys.X).value(x); + writer.name(JsonKeys.Y).value(y); + writer.name(JsonKeys.TIME_OFFSET).value(timeOffset); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements + JsonDeserializer { + + @Override + public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final Position position = new Position(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.ID: + position.id = reader.nextInt(); + break; + case JsonKeys.X: + position.x = reader.nextFloat(); + break; + case JsonKeys.Y: + position.y = reader.nextFloat(); + break; + case JsonKeys.TIME_OFFSET: + position.timeOffset = reader.nextLong(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + + position.setUnknown(unknown); + reader.endObject(); + return position; + } + } + // endregion json + } + + private @Nullable List positions; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebInteractionMoveEvent() { + super(IncrementalSource.TouchMove); + } + + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + @Nullable + public List getPositions() { + return positions; + } + + public void setPositions(final @Nullable List positions) { + this.positions = positions; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String POSITIONS = "positions"; + } + + @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); + if (positions != null && !positions.isEmpty()) { + writer.name(JsonKeys.POSITIONS).value(logger, positions); + } + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements + JsonDeserializer { + + @Override + public @NotNull RRWebInteractionMoveEvent deserialize(@NotNull ObjectReader reader, + @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebInteractionMoveEvent event = new RRWebInteractionMoveEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebInteractionMoveEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = new RRWebIncrementalSnapshotEvent.Deserializer(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.POSITIONS: + event.positions = reader.nextListOrNull(logger, new Position.Deserializer()); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (dataUnknown == null) { + dataUnknown = new HashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + break; + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt new file mode 100644 index 0000000000..cc63de72ba --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt @@ -0,0 +1,40 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebInteractionEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebInteractionEvent().apply { + timestamp = 12345678901 + id = 1 + x = 1.0f + y = 2.0f + interactionType = TouchStart + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_interaction_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_interaction_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebInteractionEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt new file mode 100644 index 0000000000..43d8fc7658 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebInteractionMoveEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebInteractionMoveEvent().apply { + timestamp = 12345678901 + positions = listOf( + Position().apply { + id = 1 + x = 1.0f + y = 2.0f + timeOffset = 100 + } + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_interaction_move_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_interaction_move_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebInteractionMoveEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/resources/json/rrweb_interaction_event.json b/sentry/src/test/resources/json/rrweb_interaction_event.json new file mode 100644 index 0000000000..f6b4b1de83 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_event.json @@ -0,0 +1,12 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 2, + "type": 7, + "id": 1, + "x": 1.0, + "y": 2.0, + "pointerType": 2 + } +} diff --git a/sentry/src/test/resources/json/rrweb_interaction_move_event.json b/sentry/src/test/resources/json/rrweb_interaction_move_event.json new file mode 100644 index 0000000000..3f181f543a --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_move_event.json @@ -0,0 +1,15 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 6, + "positions": [ + { + "id": 1, + "x": 1.0, + "y": 2.0, + "timeOffset": 100 + } + ] + } +} From 2d508c9e35de0e82fb08eed3b3e8d06699aabcc6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Apr 2024 16:05:38 +0200 Subject: [PATCH 02/10] Spotless --- .../api/sentry-android-replay.api | 37 +++- .../replay/capture/BaseCaptureStrategy.kt | 43 +++-- .../replay/util/FixedWindowCallback.java | 109 +++++++----- sentry/api/sentry.api | 160 ++++++++++++++++++ .../rrweb/RRWebIncrementalSnapshotEvent.java | 28 ++- .../sentry/rrweb/RRWebInteractionEvent.java | 33 ++-- .../rrweb/RRWebInteractionMoveEvent.java | 39 ++--- ...ebInteractionMoveEventSerializationTest.kt | 1 - 8 files changed, 328 insertions(+), 122 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 3918cde06b..337a9640f0 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -37,7 +37,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public final fun rotate (J)V } -public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable { 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 @@ -49,6 +49,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun onLowMemory ()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 pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V @@ -88,6 +89,40 @@ 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 abstract interface class io/sentry/android/replay/TouchRecorderCallback { + public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V +} + +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 + public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z + public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z + public fun dispatchKeyShortcutEvent (Landroid/view/KeyEvent;)Z + public fun dispatchPopulateAccessibilityEvent (Landroid/view/accessibility/AccessibilityEvent;)Z + public fun dispatchTouchEvent (Landroid/view/MotionEvent;)Z + public fun dispatchTrackballEvent (Landroid/view/MotionEvent;)Z + public fun onActionModeFinished (Landroid/view/ActionMode;)V + public fun onActionModeStarted (Landroid/view/ActionMode;)V + public fun onAttachedToWindow ()V + public fun onContentChanged ()V + public fun onCreatePanelMenu (ILandroid/view/Menu;)Z + public fun onCreatePanelView (I)Landroid/view/View; + public fun onDetachedFromWindow ()V + public fun onMenuItemSelected (ILandroid/view/MenuItem;)Z + public fun onMenuOpened (ILandroid/view/Menu;)Z + public fun onPanelClosed (ILandroid/view/Menu;)V + public fun onPointerCaptureChanged (Z)V + public fun onPreparePanel (ILandroid/view/View;Landroid/view/Menu;)Z + public fun onProvideKeyboardShortcuts (Ljava/util/List;Landroid/view/Menu;I)V + public fun onSearchRequested ()Z + public fun onSearchRequested (Landroid/view/SearchEvent;)Z + public fun onWindowAttributesChanged (Landroid/view/WindowManager$LayoutParams;)V + public fun onWindowFocusChanged (Z)V + public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;)Landroid/view/ActionMode; + public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z 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 9b3516f330..4236adbc0e 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 @@ -6,7 +6,6 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.ReplayRecording -import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType @@ -361,27 +360,27 @@ internal abstract class BaseCaptureStrategy( private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): RRWebIncrementalSnapshotEvent? { val event = this - return when(val action = event.actionMasked) { - MotionEvent.ACTION_MOVE -> { - // we only throttle move events as those can be overwhelming - val now = dateProvider.getCurrentTimeMillis() - if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { - return null - } - lastExecutionTime.set(now) - - RRWebInteractionMoveEvent().apply { - timestamp = dateProvider.currentTimeMillis - positions = listOf( - Position().apply { - x = event.x * recorderConfig.scaleFactorX - y = event.y * recorderConfig.scaleFactorY - id = event.getPointerId(event.actionIndex) - timeOffset = 0 // TODO: is this needed? - } - ) // TODO: support multiple pointers - } - } + return when (val action = event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.getCurrentTimeMillis() + if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { + return null + } + lastExecutionTime.set(now) + + RRWebInteractionMoveEvent().apply { + timestamp = dateProvider.currentTimeMillis + positions = listOf( + Position().apply { + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = event.getPointerId(event.actionIndex) + timeOffset = 0 // TODO: is this needed? + } + ) // TODO: support multiple pointers + } + } MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { RRWebInteractionEvent().apply { timestamp = dateProvider.currentTimeMillis diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java index d2121e8849..7245eefabe 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java @@ -1,21 +1,18 @@ /** * Adapted from https://github.com/square/curtains/tree/v1.2.5 * - * Copyright 2021 Square Inc. + *

Copyright 2021 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 + *

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 + *

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 + *

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.android.replay.util; import android.annotation.SuppressLint; @@ -35,10 +32,9 @@ import org.jetbrains.annotations.Nullable; /** - * Implementation of Window.Callback that updates the signature of - * {@link #onMenuOpened(int, Menu)} to change the menu param from - * non null to nullable to avoid runtime null check crashes. - * Issue: https://issuetracker.google.com/issues/188568911 + * Implementation of Window.Callback that updates the signature of {@link #onMenuOpened(int, Menu)} + * to change the menu param from non null to nullable to avoid runtime null check crashes. Issue: + * https://issuetracker.google.com/issues/188568911 */ public class FixedWindowCallback implements Window.Callback { @@ -48,129 +44,145 @@ public FixedWindowCallback(@Nullable Window.Callback delegate) { this.delegate = delegate; } - @Override public boolean dispatchKeyEvent(KeyEvent event) { + @Override + public boolean dispatchKeyEvent(KeyEvent event) { if (delegate == null) { return false; } return delegate.dispatchKeyEvent(event); } - @Override public boolean dispatchKeyShortcutEvent(KeyEvent event) { + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent event) { if (delegate == null) { return false; } return delegate.dispatchKeyShortcutEvent(event); } - @Override public boolean dispatchTouchEvent(MotionEvent event) { + @Override + public boolean dispatchTouchEvent(MotionEvent event) { if (delegate == null) { return false; } return delegate.dispatchTouchEvent(event); } - @Override public boolean dispatchTrackballEvent(MotionEvent event) { + @Override + public boolean dispatchTrackballEvent(MotionEvent event) { if (delegate == null) { return false; } return delegate.dispatchTrackballEvent(event); } - @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { if (delegate == null) { return false; } return delegate.dispatchGenericMotionEvent(event); } - @Override public boolean dispatchPopulateAccessibilityEvent( - AccessibilityEvent event) { + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { if (delegate == null) { return false; } return delegate.dispatchPopulateAccessibilityEvent(event); } - @Nullable @Override public View onCreatePanelView(int featureId) { + @Nullable + @Override + public View onCreatePanelView(int featureId) { if (delegate == null) { return null; } return delegate.onCreatePanelView(featureId); } - @Override public boolean onCreatePanelMenu(int featureId, @NotNull Menu menu) { + @Override + public boolean onCreatePanelMenu(int featureId, @NotNull Menu menu) { if (delegate == null) { return false; } return delegate.onCreatePanelMenu(featureId, menu); } - @Override public boolean onPreparePanel(int featureId, @Nullable View view, - @NotNull Menu menu) { + @Override + public boolean onPreparePanel(int featureId, @Nullable View view, @NotNull Menu menu) { if (delegate == null) { return false; } return delegate.onPreparePanel(featureId, view, menu); } - @Override public boolean onMenuOpened(int featureId, @Nullable Menu menu) { + @Override + public boolean onMenuOpened(int featureId, @Nullable Menu menu) { if (delegate == null) { return false; } return delegate.onMenuOpened(featureId, menu); } - @Override public boolean onMenuItemSelected(int featureId, - @NotNull MenuItem item) { + @Override + public boolean onMenuItemSelected(int featureId, @NotNull MenuItem item) { if (delegate == null) { return false; } return delegate.onMenuItemSelected(featureId, item); } - @Override public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { + @Override + public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { if (delegate == null) { return; } delegate.onWindowAttributesChanged(attrs); } - @Override public void onContentChanged() { + @Override + public void onContentChanged() { if (delegate == null) { return; } delegate.onContentChanged(); } - @Override public void onWindowFocusChanged(boolean hasFocus) { + @Override + public void onWindowFocusChanged(boolean hasFocus) { if (delegate == null) { return; } delegate.onWindowFocusChanged(hasFocus); } - @Override public void onAttachedToWindow() { + @Override + public void onAttachedToWindow() { if (delegate == null) { return; } delegate.onAttachedToWindow(); } - @Override public void onDetachedFromWindow() { + @Override + public void onDetachedFromWindow() { if (delegate == null) { return; } delegate.onDetachedFromWindow(); } - @Override public void onPanelClosed(int featureId, @NotNull Menu menu) { + @Override + public void onPanelClosed(int featureId, @NotNull Menu menu) { if (delegate == null) { return; } delegate.onPanelClosed(featureId, menu); } - @Override public boolean onSearchRequested() { + @Override + public boolean onSearchRequested() { if (delegate == null) { return false; } @@ -178,14 +190,17 @@ public FixedWindowCallback(@Nullable Window.Callback delegate) { } @SuppressLint("NewApi") - @Override public boolean onSearchRequested(SearchEvent searchEvent) { + @Override + public boolean onSearchRequested(SearchEvent searchEvent) { if (delegate == null) { return false; } return delegate.onSearchRequested(searchEvent); } - @Nullable @Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { if (delegate == null) { return null; } @@ -193,23 +208,25 @@ public FixedWindowCallback(@Nullable Window.Callback delegate) { } @SuppressLint("NewApi") - @Nullable @Override - public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, - int type) { + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int type) { if (delegate == null) { return null; } return delegate.onWindowStartingActionMode(callback, type); } - @Override public void onActionModeStarted(ActionMode mode) { + @Override + public void onActionModeStarted(ActionMode mode) { if (delegate == null) { return; } delegate.onActionModeStarted(mode); } - @Override public void onActionModeFinished(ActionMode mode) { + @Override + public void onActionModeFinished(ActionMode mode) { if (delegate == null) { return; } @@ -217,8 +234,9 @@ public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, } @SuppressLint("NewApi") - @Override public void onProvideKeyboardShortcuts(List data, - @Nullable Menu menu, int deviceId) { + @Override + public void onProvideKeyboardShortcuts( + List data, @Nullable Menu menu, int deviceId) { if (delegate == null) { return; } @@ -226,7 +244,8 @@ public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, } @SuppressLint("NewApi") - @Override public void onPointerCaptureChanged(boolean hasCapture) { + @Override + public void onPointerCaptureChanged(boolean hasCapture) { if (delegate == null) { return; } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 92a1fcd824..1147778d03 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5126,6 +5126,166 @@ public final class io/sentry/rrweb/RRWebEventType$Deserializer : io/sentry/JsonD public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } +public abstract class io/sentry/rrweb/RRWebIncrementalSnapshotEvent : io/sentry/rrweb/RRWebEvent { + public fun (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource;)V + public fun getSource ()Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public fun setSource (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource;)V +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$Deserializer { + public fun ()V + public fun deserializeValue (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource : java/lang/Enum, io/sentry/JsonSerializable { + public static final field AdoptedStyleSheet Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field CanvasMutation Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field CustomElement Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Drag Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Font Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Input Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Log Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MediaInteraction Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MouseInteraction Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MouseMove Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Mutation Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Scroll Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Selection Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field StyleDeclaration Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field StyleSheetRule Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field TouchMove Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field ViewportResize Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static fun values ()[Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$JsonKeys { + public static final field SOURCE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$Serializer { + public fun ()V + public fun serialize (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent;Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V +} + +public final class io/sentry/rrweb/RRWebInteractionEvent : io/sentry/rrweb/RRWebIncrementalSnapshotEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getDataUnknown ()Ljava/util/Map; + public fun getId ()I + public fun getInteractionType ()Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public fun getPointerType ()I + public fun getUnknown ()Ljava/util/Map; + public fun getX ()F + public fun getY ()F + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setId (I)V + public fun setInteractionType (Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType;)V + public fun setPointerType (I)V + public fun setUnknown (Ljava/util/Map;)V + public fun setX (F)V + public fun setY (F)V +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field Blur Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field Click Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field ContextMenu Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field DblClick Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field Focus Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field MouseDown Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field MouseUp Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchCancel Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchEnd Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchMove_Departed Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchStart Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static fun values ()[Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field ID Ljava/lang/String; + public static final field POINTER_TYPE Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent : io/sentry/rrweb/RRWebIncrementalSnapshotEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getDataUnknown ()Ljava/util/Map; + public fun getPositions ()Ljava/util/List; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setPositions (Ljava/util/List;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionMoveEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field POSITIONS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getId ()I + public fun getTimeOffset ()J + public fun getUnknown ()Ljava/util/Map; + public fun getX ()F + public fun getY ()F + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setId (I)V + public fun setTimeOffset (J)V + public fun setUnknown (Ljava/util/Map;)V + public fun setX (F)V + public fun setY (F)V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionMoveEvent$Position; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position$JsonKeys { + public static final field ID Ljava/lang/String; + public static final field TIME_OFFSET Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + public final class io/sentry/rrweb/RRWebMetaEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V public fun equals (Ljava/lang/Object;)Z diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java index 89b945504b..aff3c55ac3 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -3,17 +3,11 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; -import io.sentry.JsonUnknown; import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; -import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; public abstract class RRWebIncrementalSnapshotEvent extends RRWebEvent { @@ -38,14 +32,14 @@ public enum IncrementalSource implements JsonSerializable { @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + throws IOException { writer.value(ordinal()); } public static final class Deserializer implements JsonDeserializer { @Override public @NotNull IncrementalSource deserialize( - final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { return IncrementalSource.values()[reader.nextInt()]; } } @@ -73,21 +67,21 @@ public static final class JsonKeys { public static final class Serializer { public void serialize( - final @NotNull RRWebIncrementalSnapshotEvent baseEvent, - final @NotNull ObjectWriter writer, - final @NotNull ILogger logger) - throws IOException { + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull ObjectWriter writer, + final @NotNull ILogger logger) + throws IOException { writer.name(JsonKeys.SOURCE).value(logger, baseEvent.source); } } public static final class Deserializer { public boolean deserializeValue( - final @NotNull RRWebIncrementalSnapshotEvent baseEvent, - final @NotNull String nextName, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull String nextName, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { if (nextName.equals(JsonKeys.SOURCE)) { baseEvent.source = Objects.requireNonNull( diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java index e08feb52b2..e75d5d0781 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -14,8 +14,8 @@ import org.jetbrains.annotations.Nullable; @SuppressWarnings("SameNameButDifferent") -public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent implements - JsonSerializable, JsonUnknown { +public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { public enum InteractionType implements JsonSerializable { MouseUp, @@ -32,20 +32,19 @@ public enum InteractionType implements JsonSerializable { @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + throws IOException { writer.value(ordinal()); } public static final class Deserializer implements JsonDeserializer { @Override public @NotNull InteractionType deserialize( - final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { return InteractionType.values()[reader.nextInt()]; } } } - private static final int POINTER_TYPE_TOUCH = 2; private @Nullable InteractionType interactionType; @@ -139,8 +138,8 @@ public static final class JsonKeys { public static final String POINTER_TYPE = "pointerType"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { writer.beginObject(); new RRWebEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.DATA); @@ -156,7 +155,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.TYPE).value(logger, interactionType); @@ -174,12 +173,11 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL writer.endObject(); } - public static final class Deserializer implements - JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull RRWebInteractionEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + public @NotNull RRWebInteractionEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -209,13 +207,14 @@ public static final class Deserializer implements } private void deserializeData( - final @NotNull RRWebInteractionEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + final @NotNull RRWebInteractionEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { @Nullable Map dataUnknown = null; - final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = new RRWebIncrementalSnapshotEvent.Deserializer(); + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java index 9edf7e836b..86eb5e33e3 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -15,8 +15,8 @@ import org.jetbrains.annotations.Nullable; @SuppressWarnings("SameNameButDifferent") -public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent implements - JsonSerializable, JsonUnknown { +public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { public static final class Position implements JsonSerializable, JsonUnknown { @@ -82,8 +82,9 @@ public static final class JsonKeys { public static final String TIME_OFFSET = "timeOffset"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { writer.beginObject(); writer.name(JsonKeys.ID).value(id); writer.name(JsonKeys.X).value(x); @@ -99,11 +100,11 @@ public static final class JsonKeys { writer.endObject(); } - public static final class Deserializer implements - JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -187,8 +188,8 @@ public static final class JsonKeys { public static final String POSITIONS = "positions"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { writer.beginObject(); new RRWebEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.DATA); @@ -204,7 +205,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); if (positions != null && !positions.isEmpty()) { @@ -220,12 +221,11 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL writer.endObject(); } - public static final class Deserializer implements - JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull RRWebInteractionMoveEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + public @NotNull RRWebInteractionMoveEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -255,13 +255,14 @@ public static final class Deserializer implements } private void deserializeData( - final @NotNull RRWebInteractionMoveEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + final @NotNull RRWebInteractionMoveEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { @Nullable Map dataUnknown = null; - final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = new RRWebIncrementalSnapshotEvent.Deserializer(); + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt index 43d8fc7658..5df216337d 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt @@ -2,7 +2,6 @@ package io.sentry.rrweb import io.sentry.ILogger import io.sentry.protocol.SerializationUtils -import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import io.sentry.rrweb.RRWebInteractionMoveEvent.Position import org.junit.Test import org.mockito.kotlin.mock From 234b78997156ca76d3e421cbdaebb3adfe2954f3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Apr 2024 16:08:51 +0200 Subject: [PATCH 03/10] Revert --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4236adbc0e..fc33c0ca59 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 @@ -234,7 +234,7 @@ internal abstract class BaseCaptureStrategy( } breadcrumb.type == "system" -> { - breadcrumbCategory = null + breadcrumbCategory = breadcrumb.type!! breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" } From 0476132d65d1da7278fe37b6bd65b8c82039bbf4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 May 2024 23:55:55 +0200 Subject: [PATCH 04/10] Adhere to rrweb move event expectations --- .../replay/capture/BaseCaptureStrategy.kt | 57 +++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) 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 fc33c0ca59..8ed448e2c3 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 @@ -30,6 +30,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.util.Date +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory @@ -48,7 +49,6 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" - private const val DEBOUNCE_TIMEOUT = 200 private val snakecasePattern = "_[a-z]".toRegex() private val supportedNetworkData = setOf( "status_code", @@ -58,6 +58,9 @@ internal abstract class BaseCaptureStrategy( "http.response_content_length", "http.request_content_length" ) + // rrweb values + private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 + private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 } protected var cache: ReplayCache? = null @@ -66,8 +69,11 @@ internal abstract class BaseCaptureStrategy( override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir - private val currentEvents = mutableListOf() - private val lastExecutionTime = AtomicLong(0) + + private val currentEvents = CopyOnWriteArrayList() + private val currentPositions = mutableListOf() + private var touchMoveBaseline = 0L + private var lastCapturedMoveEvent = 0L protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -281,6 +287,7 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { + // TODO: rotate in buffer mode val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() if (rrwebEvent != null) { currentEvents += rrwebEvent @@ -363,30 +370,47 @@ internal abstract class BaseCaptureStrategy( return when (val action = event.actionMasked) { MotionEvent.ACTION_MOVE -> { // we only throttle move events as those can be overwhelming - val now = dateProvider.getCurrentTimeMillis() - if (lastExecutionTime.get() != 0L && lastExecutionTime.get() + DEBOUNCE_TIMEOUT > now) { + val now = dateProvider.currentTimeMillis + if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { return null } - lastExecutionTime.set(now) + lastCapturedMoveEvent = now - RRWebInteractionMoveEvent().apply { - timestamp = dateProvider.currentTimeMillis - positions = listOf( - Position().apply { - x = event.x * recorderConfig.scaleFactorX - y = event.y * recorderConfig.scaleFactorY - id = event.getPointerId(event.actionIndex) - timeOffset = 0 // TODO: is this needed? + // idk why but rrweb does it like dis + if (touchMoveBaseline == 0L) { + touchMoveBaseline = now + } + + currentPositions += Position().apply { + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + timeOffset = now - touchMoveBaseline + } + + val totalOffset = now - touchMoveBaseline + return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + RRWebInteractionMoveEvent().apply { + timestamp = now + positions = currentPositions.map { pos -> + pos.timeOffset -= totalOffset + pos } - ) // TODO: support multiple pointers + }.also { + currentPositions.clear() + touchMoveBaseline = 0L + } + } else { + null } } + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { RRWebInteractionEvent().apply { timestamp = dateProvider.currentTimeMillis x = event.x * recorderConfig.scaleFactorX y = event.y * recorderConfig.scaleFactorY - id = event.getPointerId(event.actionIndex) + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE interactionType = when (action) { MotionEvent.ACTION_UP -> InteractionType.TouchEnd MotionEvent.ACTION_DOWN -> InteractionType.TouchStart @@ -395,6 +419,7 @@ internal abstract class BaseCaptureStrategy( } } } + else -> null } } From 7823d87aad8ad98470e37dd6081e3545e1862ea1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 May 2024 23:58:12 +0200 Subject: [PATCH 05/10] formatting --- .../java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 1 + 1 file changed, 1 insertion(+) 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 8ed448e2c3..fad8aaa67a 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 @@ -58,6 +58,7 @@ internal abstract class BaseCaptureStrategy( "http.response_content_length", "http.request_content_length" ) + // rrweb values private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 From 733b490df5bb3ed7d6af295101b66402a0e64ad9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 6 May 2024 13:09:49 +0200 Subject: [PATCH 06/10] Add tests and fix deserialization --- .../main/java/io/sentry/ReplayRecording.java | 47 +++++++++++-- .../java/io/sentry/util/MapObjectReader.java | 70 ++++++++++++++++++- .../ReplayRecordingSerializationTest.kt | 6 +- .../io/sentry/util/MapObjectReaderTest.kt | 10 +++ .../test/resources/json/replay_recording.json | 2 +- 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index d04469af7d..84b6166a13 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -3,6 +3,9 @@ import io.sentry.rrweb.RRWebBreadcrumbEvent; import io.sentry.rrweb.RRWebEvent; import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent; +import io.sentry.rrweb.RRWebInteractionEvent; +import io.sentry.rrweb.RRWebInteractionMoveEvent; import io.sentry.rrweb.RRWebMetaEvent; import io.sentry.rrweb.RRWebSpanEvent; import io.sentry.rrweb.RRWebVideoEvent; @@ -141,20 +144,54 @@ public static final class Deserializer implements JsonDeserializer entry : eventMap.entrySet()) { final String key = entry.getKey(); final Object value = entry.getValue(); - if (key.equals("type")) { + if (key.equals(RRWebEvent.JsonKeys.TYPE)) { final RRWebEventType type = RRWebEventType.values()[(int) value]; switch (type) { + case IncrementalSnapshot: + Map incrementalData = + (Map) eventMap.get("data"); + if (incrementalData == null) { + incrementalData = Collections.emptyMap(); + } + final Integer sourceInt = + (Integer) + incrementalData.get(RRWebIncrementalSnapshotEvent.JsonKeys.SOURCE); + if (sourceInt != null) { + final RRWebIncrementalSnapshotEvent.IncrementalSource source = + RRWebIncrementalSnapshotEvent.IncrementalSource.values()[sourceInt]; + switch (source) { + case MouseInteraction: + final RRWebInteractionEvent interactionEvent = + new RRWebInteractionEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(interactionEvent); + break; + case TouchMove: + final RRWebInteractionMoveEvent interactionMoveEvent = + new RRWebInteractionMoveEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(interactionMoveEvent); + break; + default: + logger.log( + SentryLevel.DEBUG, + "Unsupported rrweb incremental snapshot type %s", + source); + break; + } + } + break; case Meta: final RRWebEvent metaEvent = new RRWebMetaEvent.Deserializer().deserialize(mapReader, logger); payload.add(metaEvent); break; case Custom: - Map data = (Map) eventMap.get("data"); - if (data == null) { - data = Collections.emptyMap(); + Map customData = (Map) eventMap.get("data"); + if (customData == null) { + customData = Collections.emptyMap(); } - final String tag = (String) data.get(RRWebEvent.JsonKeys.TAG); + final String tag = (String) customData.get(RRWebEvent.JsonKeys.TAG); if (tag != null) { switch (tag) { case RRWebVideoEvent.EVENT_TAG: diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index fdb6ccff2b..b04fbb9675 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -8,8 +8,10 @@ import java.io.IOException; import java.util.AbstractMap; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Date; import java.util.Deque; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.TimeZone; @@ -41,7 +43,27 @@ public void nextUnknown( public List nextListOrNull( final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) throws IOException { - return nextValueOrNull(); + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + try { + beginArray(); + List list = new ArrayList<>(); + if (hasNext()) { + do { + try { + list.add(deserializer.deserialize(this, logger)); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); + } + } while (peek() == JsonToken.BEGIN_OBJECT); + } + endArray(); + return list; + } catch (Exception e) { + throw new IOException(e); + } } @Nullable @@ -49,13 +71,55 @@ public List nextListOrNull( public Map nextMapOrNull( final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) throws IOException { - return nextValueOrNull(); + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + try { + beginObject(); + Map map = new HashMap<>(); + if (hasNext()) { + do { + try { + String key = nextName(); + map.put(key, deserializer.deserialize(this, logger)); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); + } + } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } + endObject(); + return map; + } catch (Exception e) { + throw new IOException(e); + } } @Override public @Nullable Map> nextMapOfListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - return nextValueOrNull(); + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + final @NotNull Map> result = new HashMap<>(); + + try { + beginObject(); + if (hasNext()) { + do { + final @NotNull String key = nextName(); + final @Nullable List list = nextListOrNull(logger, deserializer); + if (list != null) { + result.put(key, list); + } + } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } + endObject(); + return result; + } catch (Exception e) { + throw new IOException(e); + } } @Nullable diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index b7dfa96c4e..cff08ee2ab 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -6,6 +6,8 @@ import io.sentry.ReplayRecording import io.sentry.protocol.SerializationUtils.deserializeJson import io.sentry.protocol.SerializationUtils.serializeToString import io.sentry.rrweb.RRWebBreadcrumbEventSerializationTest +import io.sentry.rrweb.RRWebInteractionEventSerializationTest +import io.sentry.rrweb.RRWebInteractionMoveEventSerializationTest import io.sentry.rrweb.RRWebMetaEventSerializationTest import io.sentry.rrweb.RRWebSpanEventSerializationTest import io.sentry.rrweb.RRWebVideoEventSerializationTest @@ -23,7 +25,9 @@ class ReplayRecordingSerializationTest { RRWebMetaEventSerializationTest.Fixture().getSut(), RRWebVideoEventSerializationTest.Fixture().getSut(), RRWebBreadcrumbEventSerializationTest.Fixture().getSut(), - RRWebSpanEventSerializationTest.Fixture().getSut() + RRWebSpanEventSerializationTest.Fixture().getSut(), + RRWebInteractionEventSerializationTest.Fixture().getSut(), + RRWebInteractionMoveEventSerializationTest.Fixture().getSut() ) } } diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt index ad2c2344a1..a335fc71f8 100644 --- a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -75,10 +75,20 @@ class MapObjectReaderTest { writer.name("Currency").value(logger, Currency.getInstance("EUR")) writer.name("Enum").value(logger, MapObjectWriterTest.BasicEnum.A) writer.name("data").value(logger, mapOf("screen" to "MainActivity")) + writer.name("ListOfObjects").value(logger, listOf(BasicSerializable())) + writer.name("MapOfObjects").value(logger, mapOf("key" to BasicSerializable())) + writer.name("MapOfListsObjects").value(logger, mapOf("key" to listOf(BasicSerializable()))) val reader = MapObjectReader(data) reader.beginObject() assertEquals(JsonToken.NAME, reader.peek()) + assertEquals("MapOfListsObjects", reader.nextName()) + assertEquals(mapOf("key" to listOf(BasicSerializable())), reader.nextMapOfListOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("MapOfObjects", reader.nextName()) + assertEquals(mapOf("key" to BasicSerializable()), reader.nextMapOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("ListOfObjects", reader.nextName()) + assertEquals(listOf(BasicSerializable()), reader.nextListOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("data", reader.nextName()) assertEquals(mapOf("screen" to "MainActivity"), reader.nextObjectOrNull()) assertEquals("Enum", reader.nextName()) assertEquals(BasicEnum.A, BasicEnum.valueOf(reader.nextString())) diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index fac90d3803..f92c4d7cb9 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,2 +1,2 @@ {"segment_id":0} -[{"type":4,"timestamp":1234567890,"data":{"href":"https://sentry.io","height":1920,"width":1080}},{"type":5,"timestamp":12345678901,"data":{"tag":"video","payload":{"segmentId":0,"size":4000000,"duration":5000,"encoding":"h264","container":"mp4","height":1920,"width":1080,"frameCount":5,"frameRate":1,"frameRateType":"constant","left":100,"top":100}}},{"type":5,"timestamp":12345678901,"data":{"tag":"breadcrumb","payload":{"type":"default","timestamp":12345678.901,"category":"navigation","message":"message","data":{"screen":"MainActivity","state":"resumed"}}}},{"type":5,"timestamp":12345678901,"data":{"tag":"performanceSpan","payload":{"op":"resource.http","description":"https://api.github.com/users/getsentry/repos","startTimestamp":12345678.901,"endTimestamp":12345679.901,"data":{"status_code":200,"method":"POST"}}}}] +[{"type":4,"timestamp":1234567890,"data":{"href":"https://sentry.io","height":1920,"width":1080}},{"type":5,"timestamp":12345678901,"data":{"tag":"video","payload":{"segmentId":0,"size":4000000,"duration":5000,"encoding":"h264","container":"mp4","height":1920,"width":1080,"frameCount":5,"frameRate":1,"frameRateType":"constant","left":100,"top":100}}},{"type":5,"timestamp":12345678901,"data":{"tag":"breadcrumb","payload":{"type":"default","timestamp":12345678.901,"category":"navigation","message":"message","data":{"screen":"MainActivity","state":"resumed"}}}},{"type":5,"timestamp":12345678901,"data":{"tag":"performanceSpan","payload":{"op":"resource.http","description":"https://api.github.com/users/getsentry/repos","startTimestamp":12345678.901,"endTimestamp":12345679.901,"data":{"status_code":200,"method":"POST"}}}},{"type":3,"timestamp":12345678901,"data":{"source":2,"type":7,"id":1,"x":1.0,"y":2.0,"pointerType":2}},{"type":3,"timestamp":12345678901,"data":{"source":6,"positions":[{"id":1,"x":1.0,"y":2.0,"timeOffset":100}]}}] From 1217bb1ac104c76a5311c23f628e13aa328b6c7a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 6 May 2024 13:19:40 +0200 Subject: [PATCH 07/10] Rotate buffered motion events in buffer mode --- .../sentry/android/replay/capture/BaseCaptureStrategy.kt | 3 +-- .../sentry/android/replay/capture/BufferCaptureStrategy.kt | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) 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 fad8aaa67a..e245022750 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 @@ -71,7 +71,7 @@ internal abstract class BaseCaptureStrategy( override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir - private val currentEvents = CopyOnWriteArrayList() + protected val currentEvents = CopyOnWriteArrayList() private val currentPositions = mutableListOf() private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -288,7 +288,6 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - // TODO: rotate in buffer mode val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() if (rrwebEvent != null) { currentEvents += rrwebEvent 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 2d69852abf..12af281c45 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 @@ -1,5 +1,6 @@ package io.sentry.android.replay.capture +import android.view.MotionEvent import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -171,4 +172,10 @@ internal class BufferCaptureStrategy( captureStrategy.start(segmentId = currentSegment.get(), replayId = currentReplayId.get(), cleanupOldReplays = false) return captureStrategy } + + override fun onTouchEvent(event: MotionEvent) { + super.onTouchEvent(event) + val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration + currentEvents.removeAll { it.timestamp < bufferLimit } + } } From 69e5144c03e6499bcc897a3b34d2790e2848d318 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 11:01:51 +0200 Subject: [PATCH 08/10] Add Nullables --- sentry/src/main/java/io/sentry/ReplayRecording.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 84b6166a13..55595ebbc5 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -148,7 +148,7 @@ public static final class Deserializer implements JsonDeserializer incrementalData = + @Nullable Map incrementalData = (Map) eventMap.get("data"); if (incrementalData == null) { incrementalData = Collections.emptyMap(); @@ -187,7 +187,7 @@ public static final class Deserializer implements JsonDeserializer customData = (Map) eventMap.get("data"); + @Nullable Map customData = (Map) eventMap.get("data"); if (customData == null) { customData = Collections.emptyMap(); } From a3d581c6780050fb90b76b823fffee3be7a49d78 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 12:57:41 +0200 Subject: [PATCH 09/10] Address PR feedback --- .../sentry/android/replay/WindowRecorder.kt | 2 +- .../replay/capture/BaseCaptureStrategy.kt | 31 ++++++++++++++----- .../replay/capture/BufferCaptureStrategy.kt | 2 +- 3 files changed, 26 insertions(+), 9 deletions(-) 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 2ee60d7466..09f498329d 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 @@ -137,7 +137,7 @@ internal class WindowRecorder( ) : FixedWindowCallback(delegate) { override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event != null) { - val copy: MotionEvent = MotionEvent.obtain(event) + val copy: MotionEvent = MotionEvent.obtainNoHistory(event) try { touchRecorderCallback?.onTouchEvent(copy) } catch (e: Throwable) { 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 e245022750..eb5bb53696 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 @@ -30,7 +30,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.util.Date -import java.util.concurrent.CopyOnWriteArrayList +import java.util.LinkedList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory @@ -71,7 +71,8 @@ internal abstract class BaseCaptureStrategy( override val currentSegment = AtomicInteger(0) override val replayCacheDir: File? get() = cache?.replayCacheDir - protected val currentEvents = CopyOnWriteArrayList() + protected val currentEvents = LinkedList() + private val currentEventsLock = Any() private val currentPositions = mutableListOf() private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -264,11 +265,11 @@ internal abstract class BaseCaptureStrategy( } } } - currentEvents.removeAll { - if (it.timestamp > segmentTimestamp.time && it.timestamp < endTimestamp.time) { - recordingPayload += it + + rotateCurrentEvents(endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event } - it.timestamp < endTimestamp.time } val recording = ReplayRecording().apply { @@ -290,7 +291,9 @@ internal abstract class BaseCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { val rrwebEvent = event.toRRWebIncrementalSnapshotEvent() if (rrwebEvent != null) { - currentEvents += rrwebEvent + synchronized(currentEventsLock) { + currentEvents += rrwebEvent + } } } @@ -298,6 +301,20 @@ internal abstract class BaseCaptureStrategy( replayExecutor.gracefullyShutdown(options) } + protected fun rotateCurrentEvents( + until: Long, + callback: ((RRWebEvent) -> Unit)? = null, + ) { + synchronized(currentEventsLock) { + var event = currentEvents.peek() + while (event != null && event.timestamp <= until) { + callback?.invoke(event) + currentEvents.remove() + event = currentEvents.peek() + } + } + } + private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { 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 12af281c45..b365831d7a 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 @@ -176,6 +176,6 @@ internal class BufferCaptureStrategy( override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration - currentEvents.removeAll { it.timestamp < bufferLimit } + rotateCurrentEvents(bufferLimit) } } From d93e6095306ee8a0b6b3708a6108dba284aa824f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 29 May 2024 12:58:24 +0200 Subject: [PATCH 10/10] Formatting --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 2 +- sentry/src/main/java/io/sentry/ReplayRecording.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 eb5bb53696..aac08c53e5 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 @@ -303,7 +303,7 @@ internal abstract class BaseCaptureStrategy( protected fun rotateCurrentEvents( until: Long, - callback: ((RRWebEvent) -> Unit)? = null, + callback: ((RRWebEvent) -> Unit)? = null ) { synchronized(currentEventsLock) { var event = currentEvents.peek() diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 55595ebbc5..ca1c676dbd 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -148,7 +148,8 @@ public static final class Deserializer implements JsonDeserializer incrementalData = + @Nullable + Map incrementalData = (Map) eventMap.get("data"); if (incrementalData == null) { incrementalData = Collections.emptyMap(); @@ -187,7 +188,8 @@ public static final class Deserializer implements JsonDeserializer customData = (Map) eventMap.get("data"); + @Nullable + Map customData = (Map) eventMap.get("data"); if (customData == null) { customData = Collections.emptyMap(); }