Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Add `Sentry.feedback().enableFeedbackOnShake()` and `Sentry.feedback().disableFeedbackOnShake()` to toggle shake-to-report at runtime ([#5827](https://github.com/getsentry/sentry-java/pull/5827))

### Improvements

- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790))
Expand Down
8 changes: 6 additions & 2 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,12 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i
public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable {
public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, io/sentry/SentryFeedbackOptions$IShakeController, java/io/Closeable {
public fun <init> (Landroid/app/Application;)V
public fun close ()V
public fun disable ()V
public fun enable ()V
public fun isEnabled ()Z
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V
public fun onActivityDestroyed (Landroid/app/Activity;)V
public fun onActivityPaused (Landroid/app/Activity;)V
Expand All @@ -304,6 +307,7 @@ public final class io/sentry/android/core/FeedbackShakeIntegration : android/app
public fun onActivityStarted (Landroid/app/Activity;)V
public fun onActivityStopped (Landroid/app/Activity;)V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
public fun setDialog (Lio/sentry/SentryFeedbackOptions$IShakeDialog;Z)V
}

public abstract interface class io/sentry/android/core/IDebugImagesLoader {
Expand Down Expand Up @@ -553,7 +557,7 @@ public class io/sentry/android/core/SentryUserFeedbackDialog$Builder : io/sentry
public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration : io/sentry/android/core/SentryUserFeedbackForm$OptionsConfiguration {
}

public class io/sentry/android/core/SentryUserFeedbackForm : android/app/AlertDialog {
public class io/sentry/android/core/SentryUserFeedbackForm : android/app/AlertDialog, io/sentry/SentryFeedbackOptions$IShakeDialog {
protected fun onCreate (Landroid/os/Bundle;)V
protected fun onStart ()V
public fun setCancelable (Z)V
Expand Down
1 change: 1 addition & 0 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ dependencies {
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.test.runner)
testImplementation(libs.awaitility.kotlin)
testImplementation(libs.google.truth)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.mockito.inline)
testImplementation(projects.sentryTestSupport)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@

import android.app.Activity;
import android.app.Application;
import android.app.Dialog;
import android.content.Context;
import android.content.ContextWrapper;
import android.os.Bundle;
import io.sentry.IScopes;
import io.sentry.Integration;
import io.sentry.SentryFeedbackOptions;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.util.Objects;
Expand All @@ -17,18 +21,30 @@
import org.jetbrains.annotations.Nullable;

/**
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
* when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}.
* Detects shake gestures and shows the user feedback dialog when a shake is detected. {@link
* io.sentry.SentryFeedbackOptions#isUseShakeGesture()} determines the initial state; it can be
* toggled at runtime via {@code Sentry.feedback().enableFeedbackOnShake()} and {@code
* Sentry.feedback().disableFeedbackOnShake()}.
*
* <p>A single detector serves both the global toggle and individual dialogs set via {@link
* #setDialog(SentryFeedbackOptions.IShakeDialog, boolean)}. While a dialog is tracked, a shake on
* its host activity re-shows that dialog instead of creating a new form — a no-op when it is
* already visible, so a shake can never stack a second form on top of one that is showing.
*/
public final class FeedbackShakeIntegration
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {
implements Integration,
Closeable,
Application.ActivityLifecycleCallbacks,
SentryFeedbackOptions.IShakeController {

private final @NotNull Application application;
private final @NotNull SentryShakeDetector shakeDetector;
private @Nullable SentryAndroidOptions options;
private volatile boolean enabled = false;
private boolean detecting = false;
private volatile @Nullable WeakReference<SentryFeedbackOptions.IShakeDialog> dialogRef;
private boolean dialogRequestedShakeDetection = false;
private volatile @Nullable WeakReference<Activity> currentActivityRef;
private volatile boolean isDialogShowing = false;
private volatile @Nullable Runnable previousOnFormClose;

public FeedbackShakeIntegration(final @NotNull Application application) {
this.application = Objects.requireNonNull(application, "Application is required");
Expand All @@ -46,13 +62,74 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions

final @NotNull SentryAndroidOptions options = this.options;

if (!options.getFeedbackOptions().isUseShakeGesture()) {
// Always expose the runtime toggle, even when the option starts out disabled.
options.getFeedbackOptions().setShakeController(this);

if (options.getFeedbackOptions().isUseShakeGesture()) {
enable();
}
}

@Override
public synchronized void enable() {
final @Nullable SentryAndroidOptions options = this.options;
if (enabled || options == null) {
return;
}
enabled = true;
startDetecting(options);
}

@Override
public synchronized void disable() {
if (!enabled) {
return;
}
enabled = false;
if (!dialogRequestedShakeDetection || getDialog() == null) {
stopDetecting();
}
}

// Re-arm the detector in case this integration is being re-registered after a previous close()
// (e.g. a second Sentry.init reusing the same options), otherwise the closed latch would keep
// shake detection off permanently.
@Override
public boolean isEnabled() {
return enabled;
}

@Override
public synchronized void setDialog(
final @Nullable SentryFeedbackOptions.IShakeDialog dialog,
final boolean startShakeDetection) {
final @Nullable SentryAndroidOptions options = this.options;
if (options == null) {
return;
}
if (dialog == null) {
dialogRef = null;
dialogRequestedShakeDetection = false;
if (!enabled) {
stopDetecting();
}
return;
}
dialogRef = new WeakReference<>(dialog);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opted-in dialog only weakly held

High Severity

Per-form opt-in stores the dialog in a WeakReference, unlike the previous lifecycle-callback approach that kept the form alive. A create() call that does not retain the instance can be collected immediately, so a later shake finds no dialog and does nothing, while detection may keep running because nothing clears it when the weak ref goes null.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac4292c. Configure here.

if (startShakeDetection) {
dialogRequestedShakeDetection = true;
startDetecting(options);
} else {
dialogRequestedShakeDetection = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Calling setDialog(..., false) unconditionally resets dialogRequestedShakeDetection to false, causing shake detection to be stopped prematurely when disable() is called.
Severity: MEDIUM

Suggested Fix

Modify the setDialog method. When startShakeDetection is false, do not change the value of dialogRequestedShakeDetection. The flag should only be reset to false when the dialog is cleared (i.e., dialog == null).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java#L120

Potential issue: The `setDialog` method unconditionally sets
`dialogRequestedShakeDetection` to `false` when its `startShakeDetection` parameter is
`false`. This creates a bug in a specific scenario: 1. A form enables per-dialog shake
detection by calling `setDialog(this, true)`. 2. The form's `onStart()` lifecycle method
then calls `setDialog(this, false)` to track its visibility. This incorrectly resets the
`dialogRequestedShakeDetection` flag. 3. If the global shake integration is then
disabled via `disable()`, it will check the flag, find it `false`, and prematurely call
`stopDetecting()`, disabling shake detection for the form that intended to keep it
active.

Also affects:

  • sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java:242~242

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opt-in flag cleared on re-track

Medium Severity

setDialog with startShakeDetection=false always sets dialogRequestedShakeDetection to false, so an opted-in dialog that later calls setDialog(this, false) from onStart loses its keep-alive flag. A subsequent disable() then stops detection even though that dialog is still tracked, which contradicts the controller contract and the intended re-show behavior.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac4292c. Configure here.

}

private synchronized void startDetecting(final @NotNull SentryAndroidOptions options) {
if (detecting) {
return;
}
detecting = true;

// Re-arm the detector in case it was closed before, either by stopDetecting() or by a previous
// close() (e.g. a second Sentry.init reusing the same options), otherwise the closed latch
// would keep shake detection off permanently.
shakeDetector.reopen();

// Resolving the accelerometer is the most expensive part of init (the first SensorManager
Expand All @@ -72,43 +149,35 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
application.registerActivityLifecycleCallbacks(this);
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");

// In case of a deferred init, hook into any already-resumed activity
// In case of a deferred init or runtime enable, hook into any already-resumed activity
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
if (activity != null) {
currentActivityRef = new WeakReference<>(activity);
startShakeDetection(activity);
}
}

@Override
public void close() throws IOException {
private synchronized void stopDetecting() {
if (!detecting) {
return;
}
detecting = false;

application.unregisterActivityLifecycleCallbacks(this);
shakeDetector.close();
// Restore onFormClose if a dialog is still showing, since lifecycle callbacks
// are now unregistered and onActivityDestroyed cleanup won't fire.
if (isDialogShowing) {
isDialogShowing = false;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
}
previousOnFormClose = null;
}
currentActivityRef = null;
}

@Override
public synchronized void close() throws IOException {
enabled = false;
dialogRef = null;
dialogRequestedShakeDetection = false;
stopDetecting();
}

@Override
public void onActivityResumed(final @NotNull Activity activity) {
// If a dialog is showing on a different activity (e.g. user navigated via notification),
// clean up since the dialog's host activity is going away and onActivityDestroyed
// won't match currentActivity anymore.
final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null;
if (isDialogShowing && current != null && current != activity) {
isDialogShowing = false;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
}
previousOnFormClose = null;
}
currentActivityRef = new WeakReference<>(activity);
startShakeDetection(activity);
}
Expand All @@ -118,16 +187,11 @@ public void onActivityPaused(final @NotNull Activity activity) {
// Only stop if this is the activity we're tracking. When transitioning between
// activities, B.onResume may fire before A.onPause — stopping unconditionally
// would kill shake detection for the new activity.
final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null;
final @Nullable WeakReference<Activity> currentRef = currentActivityRef;
final @Nullable Activity current = currentRef != null ? currentRef.get() : null;
if (activity == current) {
stopShakeDetection();
// Keep currentActivityRef set when a dialog is showing so onActivityDestroyed
// can still match and clean up. Otherwise the cleanup condition
// (activity == current) would always be false since onPause fires
// before onDestroy.
if (!isDialogShowing) {
currentActivityRef = null;
}
currentActivityRef = null;
}
}

Expand All @@ -147,17 +211,31 @@ public void onActivitySaveInstanceState(

@Override
public void onActivityDestroyed(final @NotNull Activity activity) {
// Only reset if this is the activity that hosts the dialog — the dialog cannot
// outlive its host activity being destroyed.
final @Nullable Activity current = currentActivityRef != null ? currentActivityRef.get() : null;
if (isDialogShowing && activity == current) {
isDialogShowing = false;
currentActivityRef = null;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
// A tracked dialog cannot outlive its host activity; drop it so detection doesn't keep
// running for it (and a shake can't try to show a dead dialog).
final @Nullable SentryFeedbackOptions.IShakeDialog dialog = getDialog();
if (dialog != null && findDialogActivity(dialog) == activity) {
setDialog(null, false);
}
}

private @Nullable SentryFeedbackOptions.IShakeDialog getDialog() {
final @Nullable WeakReference<SentryFeedbackOptions.IShakeDialog> ref = dialogRef;
return ref != null ? ref.get() : null;
}

private static @Nullable Activity findDialogActivity(
final @NotNull SentryFeedbackOptions.IShakeDialog dialog) {
if (dialog instanceof Dialog) {
@Nullable Context context = ((Dialog) dialog).getContext();
while (context instanceof ContextWrapper) {
if (context instanceof Activity) {
return (Activity) context;
}
context = ((ContextWrapper) context).getBaseContext();
}
previousOnFormClose = null;
}
return null;
}

private void startShakeDetection(final @NotNull Activity activity) {
Expand All @@ -166,47 +244,47 @@ private void startShakeDetection(final @NotNull Activity activity) {
}
// Stop any existing detection (e.g. when transitioning between activities)
stopShakeDetection();
// When detection runs only for a tracked dialog, don't listen on other activities —
// a shake there couldn't show the dialog anyway.
if (!enabled) {
final @Nullable SentryFeedbackOptions.IShakeDialog dialog = getDialog();
if (dialog == null || findDialogActivity(dialog) != activity) {
return;
}
}
shakeDetector.start(
activity,
() -> {
final @Nullable WeakReference<Activity> ref = currentActivityRef;
final Activity active = ref != null ? ref.get() : null;
final Boolean inBackground = AppState.getInstance().isInBackground();
if (active != null
&& options != null
&& !isDialogShowing
&& !Boolean.TRUE.equals(inBackground)) {
active.runOnUiThread(
() -> {
if (isDialogShowing || active.isFinishing() || active.isDestroyed()) {
return;
}
if (active == null || options == null || Boolean.TRUE.equals(inBackground)) {
return;
}
// Decide on the main thread: show() sets the tracked dialog synchronously, so a
// second queued shake sees the form shown by the first instead of creating another.
active.runOnUiThread(
() -> {
if (active.isFinishing() || active.isDestroyed()) {
return;
}
// A dialog tracked for the active activity takes precedence over creating a
// new form — re-showing it is a no-op while it's already visible.
final @Nullable SentryFeedbackOptions.IShakeDialog dialog = getDialog();
if (dialog != null && findDialogActivity(dialog) == active) {
dialog.show();
return;
}
if (enabled) {
try {
isDialogShowing = true;
final Runnable captured = options.getFeedbackOptions().getOnFormClose();
previousOnFormClose = captured;
options
.getFeedbackOptions()
.setOnFormClose(
() -> {
isDialogShowing = false;
options.getFeedbackOptions().setOnFormClose(captured);
if (captured != null) {
captured.run();
}
previousOnFormClose = null;
});
new SentryUserFeedbackForm.Builder(active).create().show();
} catch (Throwable e) {
isDialogShowing = false;
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
previousOnFormClose = null;
options
.getLogger()
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
}
});
}
}
});
});
}

Expand Down
Loading
Loading