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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Features

- Make `ISpan.startChild` overloads with `SpanOptions` public ([#5927](https://github.com/getsentry/sentry-java/pull/5927))
- Add `Sentry.feedback().enableOnShake()`, `Sentry.feedback().disableOnShake()`, and `Sentry.feedback().isOnShakeEnabled()` to toggle and query shake-to-report at runtime ([#5827](https://github.com/getsentry/sentry-java/pull/5827))

### Fixes

Expand All @@ -16,7 +17,9 @@

### Performance

- Read the clock once per performance collection round instead of once per in-flight transaction ([#5934](https://github.com/getsentry/sentry-java/pull/5934))
- Reduce allocations while collecting cpu usage during transactions by reading the process cpu time via `Process.getElapsedCpuTime()` instead of parsing `/proc/self/stat` (33.6kB to 16 bytes per sample on a Pixel 3) ([#5926](https://github.com/getsentry/sentry-java/pull/5926))
- Store performance measurements as primitives, removing a boxed allocation per measurement per performance sample ([#5935](https://github.com/getsentry/sentry-java/pull/5935))

### Dependencies

Expand Down
8 changes: 7 additions & 1 deletion 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 disableOnShake ()V
public fun enableOnShake ()V
public fun isOnShakeEnabled ()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 setOnShakePaused (Z)V
}

public abstract interface class io/sentry/android/core/IDebugImagesLoader {
Expand Down Expand Up @@ -573,7 +577,9 @@ 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
public fun onDetachedFromWindow ()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 @@ -15,20 +16,34 @@
import java.lang.ref.WeakReference;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;

/**
* 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().enableOnShake()} and {@code
* Sentry.feedback().disableOnShake()}.
*
* <p>Shake detection is scoped to the resumed activity: a dialog belongs to the window of the
* activity that created it, so it can only ever be visible while that activity is resumed. Forms
* report themselves via {@link #setOnShakePaused(boolean)} and detection is then suppressed for
* that one activity, which keeps a shake from stacking a second dialog on top of a visible one
* without letting a dialog on a backgrounded activity suppress detection elsewhere.
*/
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 @Nullable WeakReference<Activity> currentActivityRef;
private volatile boolean isDialogShowing = false;
private volatile @Nullable Runnable previousOnFormClose;

/** The activity a feedback form is currently showing on, if any. */
private volatile @Nullable WeakReference<Activity> formActivityRef;

public FeedbackShakeIntegration(final @NotNull Application application) {
this.application = Objects.requireNonNull(application, "Application is required");
Expand All @@ -46,13 +61,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()) {
enableOnShake();
}
}

@Override
public synchronized void enableOnShake() {
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 +99,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 +108,58 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
}

@Override
public void close() throws IOException {
public synchronized void disableOnShake() {
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 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);
public boolean isOnShakeEnabled() {
return enabled;
}

@Override
public void setOnShakePaused(final boolean paused) {
if (paused) {
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
formActivityRef = activity == null ? null : new WeakReference<>(activity);
stopShakeDetection();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong activity owns pause

Medium Severity

setOnShakePaused(true) records CurrentActivityHolder's activity instead of the form's host activity. If those differ during a lifecycle transition, hasFormOn suppresses detection for the wrong activity and can allow a second dialog on the activity that actually owns the visible form.

Additional Locations (1)
Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit 2045e15. Configure here.

} else {
formActivityRef = null;
// The form is gone, so detection can resume for whichever activity is currently resumed.
final @Nullable WeakReference<Activity> currentRef = currentActivityRef;
final @Nullable Activity current = currentRef == null ? null : currentRef.get();
if (enabled && current != null) {
startShakeDetection(current);
}
previousOnFormClose = null;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

private boolean hasFormOn(final @NotNull Activity activity) {
final @Nullable WeakReference<Activity> ref = formActivityRef;
return ref != null && ref.get() == activity;
}

@TestOnly
@Nullable
Activity getFormActivity() {
final @Nullable WeakReference<Activity> ref = formActivityRef;
return ref == null ? null : ref.get();
}

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

Comment thread
markushi marked this conversation as resolved.
@Override
public void onActivityResumed(final @NotNull Activity activity) {
currentActivityRef = new WeakReference<>(activity);
startShakeDetection(activity);
}
Expand All @@ -118,16 +169,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,67 +192,50 @@ 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) {
return;
}
// Stop any existing detection (e.g. when transitioning between activities)
stopShakeDetection();
// A form is already visible here, so a shake could only stack a second one on top of it.
// The form has no detector of its own in this case: SentryUserFeedbackForm only starts one
// while shake-to-report is globally disabled, which is exactly when this integration is not
// detecting either.
if (hasFormOn(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;
}
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
|| !enabled
|| hasFormOn(active)
|| Boolean.TRUE.equals(inBackground)) {
return;
}
active.runOnUiThread(
() -> {
// Re-check on the main thread: shake-to-report may have been disabled, or an
// earlier queued shake may have shown a form in the meantime (the form reports
// itself synchronously in onStart).
if (!enabled || hasFormOn(active) || 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