Skip to content

Commit d743634

Browse files
authored
Merge 1eb6c23 into b1045ed
2 parents b1045ed + 1eb6c23 commit d743634

12 files changed

Lines changed: 695 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Show feedback form on device shake ([#5150](https://github.com/getsentry/sentry-java/pull/5150))
8+
- Enable via `options.getFeedbackOptions().setUseShakeGesture(true)` or manifest meta-data `io.sentry.feedback.use-shake-gesture`
9+
- Uses the device's accelerometer — no special permissions required
10+
311
## 8.34.1
412

513
### Fixes

sentry-android-core/api/sentry-android-core.api

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,19 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i
269269
public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
270270
}
271271

272+
public final class io/sentry/android/core/FeedbackShakeIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable {
273+
public fun <init> (Landroid/app/Application;)V
274+
public fun close ()V
275+
public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V
276+
public fun onActivityDestroyed (Landroid/app/Activity;)V
277+
public fun onActivityPaused (Landroid/app/Activity;)V
278+
public fun onActivityResumed (Landroid/app/Activity;)V
279+
public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V
280+
public fun onActivityStarted (Landroid/app/Activity;)V
281+
public fun onActivityStopped (Landroid/app/Activity;)V
282+
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
283+
}
284+
272285
public abstract interface class io/sentry/android/core/IDebugImagesLoader {
273286
public abstract fun clearDebugImages ()V
274287
public abstract fun loadDebugImages ()Ljava/util/List;
@@ -462,6 +475,18 @@ public final class io/sentry/android/core/SentryScreenshotOptions : io/sentry/Se
462475
public fun trackCustomMasking ()V
463476
}
464477

478+
public final class io/sentry/android/core/SentryShakeDetector : android/hardware/SensorEventListener {
479+
public fun <init> (Lio/sentry/ILogger;)V
480+
public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V
481+
public fun onSensorChanged (Landroid/hardware/SensorEvent;)V
482+
public fun start (Landroid/content/Context;Lio/sentry/android/core/SentryShakeDetector$Listener;)V
483+
public fun stop ()V
484+
}
485+
486+
public abstract interface class io/sentry/android/core/SentryShakeDetector$Listener {
487+
public abstract fun onShake ()V
488+
}
489+
465490
public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button {
466491
public fun <init> (Landroid/content/Context;)V
467492
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ static void installDefaultIntegrations(
410410
(Application) context, buildInfoProvider, activityFramesTracker));
411411
options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context));
412412
options.addIntegration(new UserInteractionIntegration((Application) context, loadClass));
413+
options.addIntegration(new FeedbackShakeIntegration((Application) context));
413414
if (isFragmentAvailable) {
414415
options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true));
415416
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package io.sentry.android.core;
2+
3+
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;
4+
5+
import android.app.Activity;
6+
import android.app.Application;
7+
import android.os.Bundle;
8+
import io.sentry.IScopes;
9+
import io.sentry.Integration;
10+
import io.sentry.SentryLevel;
11+
import io.sentry.SentryOptions;
12+
import io.sentry.util.Objects;
13+
import java.io.Closeable;
14+
import java.io.IOException;
15+
import org.jetbrains.annotations.NotNull;
16+
import org.jetbrains.annotations.Nullable;
17+
18+
/**
19+
* Detects shake gestures and shows the user feedback dialog when a shake is detected. Only active
20+
* when {@link io.sentry.SentryFeedbackOptions#isUseShakeGesture()} returns {@code true}.
21+
*/
22+
public final class FeedbackShakeIntegration
23+
implements Integration, Closeable, Application.ActivityLifecycleCallbacks {
24+
25+
private final @NotNull Application application;
26+
private final @NotNull SentryShakeDetector shakeDetector;
27+
private @Nullable SentryAndroidOptions options;
28+
private volatile @Nullable Activity currentActivity;
29+
private volatile boolean isDialogShowing = false;
30+
private volatile @Nullable Runnable previousOnFormClose;
31+
32+
public FeedbackShakeIntegration(final @NotNull Application application) {
33+
this.application = Objects.requireNonNull(application, "Application is required");
34+
this.shakeDetector = new SentryShakeDetector(io.sentry.NoOpLogger.getInstance());
35+
}
36+
37+
@Override
38+
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
39+
this.options = (SentryAndroidOptions) sentryOptions;
40+
41+
if (!this.options.getFeedbackOptions().isUseShakeGesture()) {
42+
return;
43+
}
44+
45+
shakeDetector.init(application, options.getLogger());
46+
47+
addIntegrationToSdkVersion("FeedbackShake");
48+
application.registerActivityLifecycleCallbacks(this);
49+
options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");
50+
51+
// In case of a deferred init, hook into any already-resumed activity
52+
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
53+
if (activity != null) {
54+
currentActivity = activity;
55+
startShakeDetection(activity);
56+
}
57+
}
58+
59+
@Override
60+
public void close() throws IOException {
61+
application.unregisterActivityLifecycleCallbacks(this);
62+
stopShakeDetection();
63+
// Restore onFormClose if a dialog is still showing, since lifecycle callbacks
64+
// are now unregistered and onActivityDestroyed cleanup won't fire.
65+
if (isDialogShowing) {
66+
isDialogShowing = false;
67+
if (options != null) {
68+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
69+
}
70+
previousOnFormClose = null;
71+
}
72+
currentActivity = null;
73+
}
74+
75+
@Override
76+
public void onActivityResumed(final @NotNull Activity activity) {
77+
// If a dialog is showing on a different activity (e.g. user navigated via notification),
78+
// clean up since the dialog's host activity is going away and onActivityDestroyed
79+
// won't match currentActivity anymore.
80+
if (isDialogShowing && currentActivity != null && currentActivity != activity) {
81+
isDialogShowing = false;
82+
if (options != null) {
83+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
84+
}
85+
previousOnFormClose = null;
86+
}
87+
currentActivity = activity;
88+
startShakeDetection(activity);
89+
}
90+
91+
@Override
92+
public void onActivityPaused(final @NotNull Activity activity) {
93+
// Only stop if this is the activity we're tracking. When transitioning between
94+
// activities, B.onResume may fire before A.onPause — stopping unconditionally
95+
// would kill shake detection for the new activity.
96+
if (activity == currentActivity) {
97+
stopShakeDetection();
98+
// Keep currentActivity set when a dialog is showing so onActivityDestroyed
99+
// can still match and clean up. Otherwise the cleanup condition
100+
// (activity == currentActivity) would always be false since onPause fires
101+
// before onDestroy.
102+
if (!isDialogShowing) {
103+
currentActivity = null;
104+
}
105+
}
106+
}
107+
108+
@Override
109+
public void onActivityCreated(
110+
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}
111+
112+
@Override
113+
public void onActivityStarted(final @NotNull Activity activity) {}
114+
115+
@Override
116+
public void onActivityStopped(final @NotNull Activity activity) {}
117+
118+
@Override
119+
public void onActivitySaveInstanceState(
120+
final @NotNull Activity activity, final @NotNull Bundle outState) {}
121+
122+
@Override
123+
public void onActivityDestroyed(final @NotNull Activity activity) {
124+
// Only reset if this is the activity that hosts the dialog — the dialog cannot
125+
// outlive its host activity being destroyed.
126+
if (isDialogShowing && activity == currentActivity) {
127+
isDialogShowing = false;
128+
currentActivity = null;
129+
if (options != null) {
130+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
131+
}
132+
previousOnFormClose = null;
133+
}
134+
}
135+
136+
private void startShakeDetection(final @NotNull Activity activity) {
137+
if (options == null) {
138+
return;
139+
}
140+
// Stop any existing detection (e.g. when transitioning between activities)
141+
stopShakeDetection();
142+
shakeDetector.start(
143+
activity,
144+
() -> {
145+
final Activity active = currentActivity;
146+
final Boolean inBackground = AppState.getInstance().isInBackground();
147+
if (active != null
148+
&& options != null
149+
&& !isDialogShowing
150+
&& !Boolean.TRUE.equals(inBackground)) {
151+
active.runOnUiThread(
152+
() -> {
153+
if (isDialogShowing) {
154+
return;
155+
}
156+
try {
157+
isDialogShowing = true;
158+
final Runnable captured = options.getFeedbackOptions().getOnFormClose();
159+
previousOnFormClose = captured;
160+
options
161+
.getFeedbackOptions()
162+
.setOnFormClose(
163+
() -> {
164+
isDialogShowing = false;
165+
options.getFeedbackOptions().setOnFormClose(captured);
166+
if (captured != null) {
167+
captured.run();
168+
}
169+
previousOnFormClose = null;
170+
});
171+
new SentryUserFeedbackDialog.Builder(active).create().show();
172+
} catch (Throwable e) {
173+
isDialogShowing = false;
174+
options.getFeedbackOptions().setOnFormClose(previousOnFormClose);
175+
previousOnFormClose = null;
176+
options
177+
.getLogger()
178+
.log(SentryLevel.ERROR, "Failed to show feedback dialog on shake.", e);
179+
}
180+
});
181+
}
182+
});
183+
}
184+
185+
private void stopShakeDetection() {
186+
shakeDetector.stop();
187+
}
188+
}

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ final class ManifestMetadataReader {
167167

168168
static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding";
169169

170+
static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture";
171+
170172
static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable";
171173

172174
static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url";
@@ -661,6 +663,9 @@ static void applyMetadata(
661663
metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser()));
662664
feedbackOptions.setShowBranding(
663665
readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding()));
666+
feedbackOptions.setUseShakeGesture(
667+
readBool(
668+
metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture()));
664669

665670
options.setEnableSpotlight(
666671
readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight()));

0 commit comments

Comments
 (0)