Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Features

- Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150))
- Enable via `options.getFeedbackOptions().setUseShakeGesture(true)`
- Uses the device's accelerometer โ€” no special permissions required

### Fixes

- Android: Add proguard rules to prevent error about missing Replay classes ([#5153](https://github.com/getsentry/sentry-java/pull/5153))
Expand Down
25 changes: 25 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,19 @@ 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 fun <init> (Landroid/app/Application;)V
public fun close ()V
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 register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public abstract interface class io/sentry/android/core/IDebugImagesLoader {
public abstract fun clearDebugImages ()V
public abstract fun loadDebugImages ()Ljava/util/List;
Expand Down Expand Up @@ -457,6 +470,18 @@ public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/Se
public fun trackCustomMasking ()V
}

public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener {
public fun <init> (Lio/sentry/ILogger;)V
public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V
public fun onSensorChanged (Landroid/hardware/SensorEvent;)V
public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V
public fun stop ()V
}

public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener {
public abstract fun onShake ()V
}

public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
public fun <init> (Landroid/content/Context;)V
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ static void installDefaultIntegrations(
(Application) context, buildInfoProvider, activityFramesTracker));
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
options.addIntegration(new FeedbackShakeIntegration((Application) context));
if (isFragmentAvailable) {
options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package io.sentry.android.core;

import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;

import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import io.sentry.IScopes;
import io.sentry.Integration;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.util.Objects;
import java.io.Closeable;
import java.io.IOException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
Copy link
Contributor

Choose a reason for hiding this comment

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

I know the end goal is to add this to improve user feedback, but the way it is, it's doing nothing related to user feedback.
Might be a good idea to rename this to FeedbackShakeIntegration so the name of it self explains the usage of it, instead of leaving it generic, what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated with 1fd4f5b

* when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}.
*/
public final class FeedbackShakeIntegration
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {

private final @NotNull Application application;
private @Nullable SentryShakeDetector shakeDetector;
private @Nullable SentryAndroidOptions options;
private volatile @Nullable Activity currentActivity;
private volatile boolean isDialogShowing = false;
private volatile @Nullable Activity dialogActivity;
private volatile @Nullable Runnable previousOnFormClose;

public FeedbackShakeIntegration(final @NotNull Application application) {
this.application = Objects.requireNonNull(application, "Application is required");
}

@Override
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
this.options = (SentryAndroidOptions) sentryOptions;

if (!this.options.getFeedbackOptions().isUseShakeGesture()) {
return;
}

addIntegrationToSdkVersion("FeedbackShake");
application.registerActivityLifecycleCallbacks(this);
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");

// In case of a deferred init, hook into any already-resumed activity
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
if (activity != null) {
currentActivity = activity;
startShakeDetection(activity);
}
}

@Override
public void close() throws IOException {
application.unregisterActivityLifecycleCallbacks(this);
stopShakeDetection();
}

@Override
public void onActivityResumed(final @NotNull Activity activity) {
currentActivity = activity;
startShakeDetection(activity);
}

@Override
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.
if (activity == currentActivity) {
stopShakeDetection();
currentActivity = null;
}
}

@Override
public void onActivityCreated(
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}

@Override
public void onActivityStarted(final @NotNull Activity activity) {}

@Override
public void onActivityStopped(final @NotNull Activity activity) {}

@Override
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.
if (activity == dialogActivity) {
isDialogShowing = false;
dialogActivity = null;
if (options != null) {
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
}
previousOnFormClose = null;
}
}

private void startShakeDetection(final @NotNull Activity activity) {
if (options == null) {
return;
}
// Stop any existing detector (e.g. when transitioning between activities)
stopShakeDetection();
shakeDetector = new SentryShakeDetector(options.getLogger());
shakeDetector.start(
activity,
() -> {
final Activity active = currentActivity;
if (active != null && options != null && !isDialogShowing) {
active.runOnUiThread(
() -> {
if (isDialogShowing) {
return;
}
try {
isDialogShowing = true;
dialogActivity = active;
previousOnFormClose = options.getFeedbackOptions().getOnFormClose();
options
.getFeedbackOptions()
.setOnFormClose(
() -> {
isDialogShowing = false;
dialogActivity = null;
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
if (previousOnFormClose != null) {
previousOnFormClose.run();
}
previousOnFormClose = null;
});
options.getFeedbackOptions().getDialogHandler().showDialog(null, null);
} catch (Throwable e) {
isDialogShowing = false;
dialogActivity = null;
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
previousOnFormClose = null;
options
.getLogger()
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
}
});
}
});
}

private void stopShakeDetection() {
if (shakeDetector != null) {
shakeDetector.stop();
shakeDetector = null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package io.sentry.android.core;

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.SystemClock;
import io.sentry.ILogger;
import io.sentry.SentryLevel;
import java.util.concurrent.atomic.AtomicLong;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Detects shake gestures using the device's accelerometer.
*
* <p>The accelerometer sensor (TYPE_ACCELEROMETER) does NOT require any special permissions on
* Android. The BODY_SENSORS permission is only needed for heart rate and similar body sensors.
*
* <p>Requires at least {@link #SHAKE_COUNT_THRESHOLD} accelerometer readings above {@link
* #SHAKE_THRESHOLD_GRAVITY} within {@link #SHAKE_WINDOW_MS} to trigger a shake event.
*/
@ApiStatus.Internal
public final class SentryShakeDetector implements SensorEventListener {

private static final float SHAKE_THRESHOLD_GRAVITY = 2.7f;
private static final int SHAKE_WINDOW_MS = 1500;
private static final int SHAKE_COUNT_THRESHOLD = 2;
private static final int SHAKE_COOLDOWN_MS = 1000;

private @Nullable SensorManager sensorManager;
private final @NotNull AtomicLong lastShakeTimestamp = new AtomicLong(0);
private volatile @Nullable Listener listener;
private final @NotNull ILogger logger;

private int shakeCount = 0;
private long firstShakeTimestamp = 0;

public interface Listener {
void onShake();
}

public SentryShakeDetector(final @NotNull ILogger logger) {
this.logger = logger;
}

public void start(final @NotNull Context context, final @NotNull Listener shakeListener) {
this.listener = shakeListener;
sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
if (sensorManager == null) {
logger.log(SentryLevel.WARNING, "SensorManager is not available. Shake detection disabled.");
return;
}
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
if (accelerometer == null) {
logger.log(
SentryLevel.WARNING, "Accelerometer sensor not available. Shake detection disabled.");
return;
}
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL);
}

public void stop() {
listener = null;
if (sensorManager != null) {
sensorManager.unregisterListener(this);
sensorManager = null;
}
}

@Override
public void onSensorChanged(final @NotNull SensorEvent event) {
if (event.sensor.getType() != Sensor.TYPE_ACCELEROMETER) {
return;
}
float gX = event.values[0] / SensorManager.GRAVITY_EARTH;
float gY = event.values[1] / SensorManager.GRAVITY_EARTH;
float gZ = event.values[2] / SensorManager.GRAVITY_EARTH;
double gForceSquared = gX * gX + gY * gY + gZ * gZ;
if (gForceSquared > SHAKE_THRESHOLD_GRAVITY * SHAKE_THRESHOLD_GRAVITY) {
long now = SystemClock.elapsedRealtime();

// Reset counter if outside the detection window
if (now - firstShakeTimestamp > SHAKE_WINDOW_MS) {
shakeCount = 0;
firstShakeTimestamp = now;
}

shakeCount++;

if (shakeCount >= SHAKE_COUNT_THRESHOLD) {
// Enforce cooldown so we don't fire repeatedly
long lastShake = lastShakeTimestamp.get();
if (now - lastShake > SHAKE_COOLDOWN_MS) {
lastShakeTimestamp.set(now);
shakeCount = 0;
final @Nullable Listener currentListener = listener;
if (currentListener != null) {
currentListener.onShake();
}
}
}
}
}

@Override
public void onAccuracyChanged(final @NotNull Sensor sensor, final int accuracy) {
// Not needed for shake detection.
}
}
Loading
Loading