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
7 changes: 6 additions & 1 deletion sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,20 @@ 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
public fun onActivityResumed (Landroid/app/Activity;)V
public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V
public fun onActivityStarted (Landroid/app/Activity;)V
public fun onActivityStopped (Landroid/app/Activity;)V
public fun pauseDetection (Z)V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

Expand Down Expand Up @@ -556,6 +560,7 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$
public class io/sentry/android/core/SentryUserFeedbackForm : android/app/AlertDialog {
protected fun onCreate (Landroid/os/Bundle;)V
protected fun onStart ()V
protected fun onStop ()V
public fun setCancelable (Z)V
public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V
public fun show ()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 @@ -7,6 +7,7 @@
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 +18,27 @@
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>While any feedback dialog is visible it pauses shake handling via {@link
* #pauseDetection(boolean)}, so a shake can never stack a second dialog on top of one that is
* already showing — no matter how the visible dialog was opened.
*/
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 volatile boolean paused = 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 +56,25 @@ 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;

// 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.
// Re-arm the detector in case it was closed before, either by disable() 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,7 +94,7 @@ 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);
Expand All @@ -81,34 +103,34 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
}

@Override
public void close() throws IOException {
public synchronized void disable() {
if (!enabled) {
return;
}
enabled = 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 boolean isEnabled() {
return enabled;
}

@Override
public void pauseDetection(final boolean paused) {
this.paused = paused;
}

@Override
public void close() throws IOException {
disable();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pause stuck across activity navigation

Medium Severity

Navigating to another activity while a feedback dialog is still open leaves paused set, because Dialog.onStop / onDetachedFromWindow do not run when the host activity is only stopped. The previous onActivityResumed cleanup for this case was removed, so shake-to-report stays inert on the new activity until the old dialog is dismissed or destroyed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 252b24c. Configure here.

@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 +140,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 @@ -146,19 +163,7 @@ public void onActivitySaveInstanceState(
final @NotNull Activity activity, final @NotNull Bundle outState) {}

@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);
}
previousOnFormClose = null;
}
}
public void onActivityDestroyed(final @NotNull Activity activity) {}

private void startShakeDetection(final @NotNull Activity activity) {
if (options == null) {
Expand All @@ -172,41 +177,24 @@ private void startShakeDetection(final @NotNull Activity 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;
}
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);
}
});
if (active == null || options == null || paused || Boolean.TRUE.equals(inBackground)) {
return;
}
active.runOnUiThread(
() -> {
// Re-check on the main thread: an earlier queued shake may have shown a form
// in the meantime (the form pauses detection synchronously in onStart).
if (paused || active.isFinishing() || active.isDestroyed()) {
return;
}
try {
new SentryUserFeedbackForm.Builder(active).create().show();
} catch (Throwable e) {
options
.getLogger()
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
}
});
});
}

Expand Down
Loading
Loading