Skip to content

Commit 8558cac

Browse files
romtsnclaudegetsentry-bot
authored
feat(feedback): Add per-form shake detection and sample app showcases (#5353)
* feat(feedback): Add per-form shake detection and sample app showcases Resolve feedback options once in the constructor and reuse them in onCreate, avoiding duplicate resolution. Add per-form shake-to-show support via SentryShakeDetector that skips activation when the global FeedbackShakeIntegration is already enabled. Update sample app with custom form builder, auto-dismiss, programmatic capture, and shake-to-show examples. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(changelog): Add per-form shake detection entry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Format code * docs(changelog): Add usage example for per-form shake detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(changelog): Use Kotlin example and clarify per-screen usage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ref(feedback): Extract shared shake listener in SentryUserFeedbackForm Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
1 parent b469e46 commit 8558cac

4 files changed

Lines changed: 232 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
- All deprecated APIs will be removed in the next major version
1313
- Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350))
1414
- It will be removed in the next major version
15+
- Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353))
16+
- Useful for enabling shake-to-report on specific screens instead of globally
17+
```kotlin
18+
SentryUserFeedbackForm.Builder(activity)
19+
.configurator { it.isUseShakeGesture = true }
20+
.create()
21+
```
1522

1623
### Fixes
1724

‎sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java‎

Lines changed: 120 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package io.sentry.android.core;
22

3+
import android.app.Activity;
34
import android.app.AlertDialog;
5+
import android.app.Application;
46
import android.content.Context;
7+
import android.content.ContextWrapper;
58
import android.os.Bundle;
69
import android.view.View;
710
import android.view.Window;
@@ -20,6 +23,7 @@
2023
import io.sentry.protocol.Feedback;
2124
import io.sentry.protocol.SentryId;
2225
import io.sentry.protocol.User;
26+
import java.lang.ref.WeakReference;
2327
import org.jetbrains.annotations.NotNull;
2428
import org.jetbrains.annotations.Nullable;
2529

@@ -30,8 +34,10 @@ public class SentryUserFeedbackForm extends AlertDialog {
3034
private final @Nullable SentryId associatedEventId;
3135
private @Nullable OnDismissListener delegate;
3236

33-
private final @Nullable OptionsConfiguration configuration;
34-
private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator;
37+
private final @NotNull SentryFeedbackOptions resolvedFeedbackOptions;
38+
39+
private @Nullable SentryShakeDetector shakeDetector;
40+
private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks;
3541

3642
SentryUserFeedbackForm(
3743
final @NotNull Context context,
@@ -41,9 +47,118 @@ public class SentryUserFeedbackForm extends AlertDialog {
4147
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
4248
super(context, themeResId);
4349
this.associatedEventId = associatedEventId;
44-
this.configuration = configuration;
45-
this.configurator = configurator;
50+
this.resolvedFeedbackOptions =
51+
new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions());
52+
if (configuration != null) {
53+
configuration.configure(context, resolvedFeedbackOptions);
54+
}
55+
if (configurator != null) {
56+
configurator.configure(resolvedFeedbackOptions);
57+
}
4658
SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget");
59+
maybeStartShakeDetection(context);
60+
}
61+
62+
private void maybeStartShakeDetection(final @NotNull Context context) {
63+
final @NotNull SentryFeedbackOptions globalFeedbackOptions =
64+
Sentry.getCurrentScopes().getOptions().getFeedbackOptions();
65+
if (!resolvedFeedbackOptions.isUseShakeGesture() || globalFeedbackOptions.isUseShakeGesture()) {
66+
return;
67+
}
68+
final @Nullable Activity activity = getActivity(context);
69+
if (activity == null) {
70+
return;
71+
}
72+
final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions();
73+
shakeDetector = new SentryShakeDetector(options.getLogger());
74+
final @NotNull WeakReference<Activity> activityRef = new WeakReference<>(activity);
75+
shakeDetector.start(activity, shakeListener(activityRef));
76+
final @NotNull Application app = activity.getApplication();
77+
shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef);
78+
app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks);
79+
}
80+
81+
private void stopShakeDetection() {
82+
if (shakeDetector != null) {
83+
shakeDetector.close();
84+
shakeDetector = null;
85+
}
86+
if (shakeLifecycleCallbacks != null) {
87+
final @Nullable Activity activity = getActivity(getContext());
88+
if (activity != null) {
89+
activity.getApplication().unregisterActivityLifecycleCallbacks(shakeLifecycleCallbacks);
90+
}
91+
shakeLifecycleCallbacks = null;
92+
}
93+
}
94+
95+
private @NotNull SentryShakeDetector.Listener shakeListener(
96+
final @NotNull WeakReference<Activity> activityRef) {
97+
return () -> {
98+
final @Nullable Activity active = activityRef.get();
99+
if (active != null && !active.isFinishing() && !active.isDestroyed()) {
100+
active.runOnUiThread(
101+
() -> {
102+
if (!active.isFinishing() && !active.isDestroyed()) {
103+
show();
104+
}
105+
});
106+
}
107+
};
108+
}
109+
110+
private static @Nullable Activity getActivity(final @NotNull Context context) {
111+
Context current = context;
112+
while (current instanceof ContextWrapper) {
113+
if (current instanceof Activity) {
114+
return (Activity) current;
115+
}
116+
current = ((ContextWrapper) current).getBaseContext();
117+
}
118+
return null;
119+
}
120+
121+
private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
122+
private final @NotNull WeakReference<Activity> activityRef;
123+
124+
ShakeLifecycleCallbacks(final @NotNull WeakReference<Activity> activityRef) {
125+
this.activityRef = activityRef;
126+
}
127+
128+
@Override
129+
public void onActivityResumed(final @NotNull Activity activity) {
130+
if (activity == activityRef.get() && shakeDetector != null) {
131+
shakeDetector.start(activity, shakeListener(activityRef));
132+
}
133+
}
134+
135+
@Override
136+
public void onActivityPaused(final @NotNull Activity activity) {
137+
if (activity == activityRef.get() && shakeDetector != null) {
138+
shakeDetector.stop();
139+
}
140+
}
141+
142+
@Override
143+
public void onActivityDestroyed(final @NotNull Activity activity) {
144+
if (activity == activityRef.get()) {
145+
stopShakeDetection();
146+
}
147+
}
148+
149+
@Override
150+
public void onActivityCreated(
151+
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {}
152+
153+
@Override
154+
public void onActivityStarted(final @NotNull Activity activity) {}
155+
156+
@Override
157+
public void onActivityStopped(final @NotNull Activity activity) {}
158+
159+
@Override
160+
public void onActivitySaveInstanceState(
161+
final @NotNull Activity activity, final @NotNull Bundle outState) {}
47162
}
48163

49164
@Override
@@ -63,14 +178,7 @@ protected void onCreate(Bundle savedInstanceState) {
63178
}
64179
setCancelable(isCancelable);
65180

66-
final @NotNull SentryFeedbackOptions feedbackOptions =
67-
new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions());
68-
if (configuration != null) {
69-
configuration.configure(getContext(), feedbackOptions);
70-
}
71-
if (configurator != null) {
72-
configurator.configure(feedbackOptions);
73-
}
181+
final @NotNull SentryFeedbackOptions feedbackOptions = resolvedFeedbackOptions;
74182
final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title);
75183
final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo);
76184
final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name);

‎sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@
271271
<meta-data
272272
android:name="io.sentry.anr.enable-fingerprinting"
273273
android:value="true" />
274+
<!-- Enable feedback on shake globally -->
274275
<meta-data
275276
android:name="io.sentry.feedback.use-shake-gesture"
276277
android:value="true" />

‎sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt‎

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Settings
4343
import androidx.compose.material.icons.filled.Speed
4444
import androidx.compose.material.icons.filled.Videocam
4545
import androidx.compose.material3.AlertDialog
46+
import androidx.compose.material3.Button
4647
import androidx.compose.material3.ExperimentalMaterial3Api
4748
import androidx.compose.material3.Icon
4849
import androidx.compose.material3.MaterialTheme
@@ -62,6 +63,7 @@ import androidx.compose.runtime.mutableStateOf
6263
import androidx.compose.runtime.remember
6364
import androidx.compose.runtime.rememberCoroutineScope
6465
import androidx.compose.runtime.setValue
66+
import androidx.compose.ui.Alignment
6567
import androidx.compose.ui.ExperimentalComposeUiApi
6668
import androidx.compose.ui.Modifier
6769
import androidx.compose.ui.draw.rotate
@@ -79,8 +81,9 @@ import io.sentry.MeasurementUnit
7981
import io.sentry.Sentry
8082
import io.sentry.SentryLogLevel
8183
import io.sentry.UpdateStatus
84+
import io.sentry.android.core.SentryUserFeedbackForm
8285
import io.sentry.compose.SentryTraced
83-
import io.sentry.compose.SentryUserFeedbackButton
86+
import io.sentry.protocol.Feedback
8487
import io.sentry.protocol.User
8588
import java.io.File
8689
import java.io.FileOutputStream
@@ -615,8 +618,106 @@ fun UserFeedbackScreen() {
615618
}
616619
}
617620

618-
// SentryUserFeedbackButton as a special item
619-
item(span = { GridItemSpan(maxLineSpan) }) { SentryUserFeedbackButton(modifier = Modifier) }
621+
// Bring up User Feedback Form from a custom button using the global Sentry.feedback() API
622+
item(span = { GridItemSpan(maxLineSpan) }) {
623+
Button(modifier = Modifier, onClick = { Sentry.feedback().show() }) {
624+
Row(
625+
verticalAlignment = Alignment.CenterVertically,
626+
horizontalArrangement = Arrangement.Center,
627+
) {
628+
Icon(
629+
painter =
630+
painterResource(
631+
id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24
632+
),
633+
contentDescription = null,
634+
)
635+
Spacer(Modifier.padding(horizontal = 4.dp))
636+
Text(text = "Report a Bug")
637+
}
638+
}
639+
}
640+
641+
// Create a SentryUserFeedbackForm programmatically and show it
642+
item(span = { GridItemSpan(maxLineSpan) }) {
643+
Button(
644+
modifier = Modifier,
645+
onClick = {
646+
SentryUserFeedbackForm.Builder(activity)
647+
.configurator { options ->
648+
options.formTitle = "Custom Form"
649+
options.submitButtonLabel = "Send"
650+
options.cancelButtonLabel = "Never mind"
651+
options.messageLabel = "What happened?"
652+
options.messagePlaceholder = "Describe the issue..."
653+
options.isShowBranding = false
654+
options.isNameRequired = true
655+
options.isEmailRequired = true
656+
options.setOnSubmitSuccess { feedback ->
657+
Toast.makeText(activity, "Thanks for the feedback!", Toast.LENGTH_SHORT).show()
658+
}
659+
}
660+
.create()
661+
.show()
662+
},
663+
) {
664+
Text(text = "Custom Form (Builder)")
665+
}
666+
}
667+
668+
// Showcases how to manually show and dismiss a form programmatically
669+
item(span = { GridItemSpan(maxLineSpan) }) {
670+
Button(
671+
modifier = Modifier,
672+
onClick = {
673+
val form =
674+
SentryUserFeedbackForm.Builder(activity)
675+
.configurator { options -> options.formTitle = "Quick! You have 2 seconds" }
676+
.create()
677+
form.show()
678+
Handler(Looper.getMainLooper()).postDelayed({ form.dismiss() }, 2000)
679+
},
680+
) {
681+
Text(text = "Auto-dismiss Form (2s)")
682+
}
683+
}
684+
685+
// Send feedback programmatically without showing a form
686+
item(span = { GridItemSpan(maxLineSpan) }) {
687+
Button(
688+
modifier = Modifier,
689+
onClick = {
690+
val feedback =
691+
Feedback("The app crashed when I tapped the button").apply {
692+
name = "Jane Doe"
693+
contactEmail = "jane@example.com"
694+
url = "https://example.com/page"
695+
}
696+
val eventId = Sentry.feedback().capture(feedback)
697+
Toast.makeText(activity, "Feedback sent: $eventId", Toast.LENGTH_SHORT).show()
698+
},
699+
) {
700+
Text(text = "Send Feedback (no form)")
701+
}
702+
}
703+
704+
// Enable shake-to-show for a specific form instance
705+
item(span = { GridItemSpan(maxLineSpan) }) {
706+
Button(
707+
modifier = Modifier,
708+
onClick = {
709+
SentryUserFeedbackForm.Builder(activity)
710+
.configurator { options ->
711+
options.isUseShakeGesture = true
712+
options.formTitle = "Shake Feedback"
713+
}
714+
.create()
715+
Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT).show()
716+
},
717+
) {
718+
Text(text = "Enable Shake-to-Show")
719+
}
720+
}
620721
}
621722
}
622723

0 commit comments

Comments
 (0)