From f6f626b605cad1025c80eda3528de74750329fcb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Apr 2026 16:40:08 +0200 Subject: [PATCH 1/6] 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 --- .../android/core/SentryUserFeedbackForm.java | 132 ++++++++++++++++-- .../src/main/AndroidManifest.xml | 1 + .../io/sentry/samples/android/MainActivity.kt | 103 +++++++++++++- 3 files changed, 222 insertions(+), 14 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 0babe475491..1ca1bef2123 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -1,7 +1,10 @@ package io.sentry.android.core; +import android.app.Activity; import android.app.AlertDialog; +import android.app.Application; import android.content.Context; +import android.content.ContextWrapper; import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -18,6 +21,7 @@ import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -28,8 +32,10 @@ public class SentryUserFeedbackForm extends AlertDialog { private final @Nullable SentryId associatedEventId; private @Nullable OnDismissListener delegate; - private final @Nullable OptionsConfiguration configuration; - private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; + private final @NotNull SentryFeedbackOptions resolvedFeedbackOptions; + + private @Nullable SentryShakeDetector shakeDetector; + private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks; SentryUserFeedbackForm( final @NotNull Context context, @@ -39,9 +45,118 @@ public class SentryUserFeedbackForm extends AlertDialog { final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { super(context, themeResId); this.associatedEventId = associatedEventId; - this.configuration = configuration; - this.configurator = configurator; + this.resolvedFeedbackOptions = + new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); + if (configuration != null) { + configuration.configure(context, resolvedFeedbackOptions); + } + if (configurator != null) { + configurator.configure(resolvedFeedbackOptions); + } SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); + maybeStartShakeDetection(context); + } + + private void maybeStartShakeDetection(final @NotNull Context context) { + final @NotNull SentryFeedbackOptions globalFeedbackOptions = + Sentry.getCurrentScopes().getOptions().getFeedbackOptions(); + if (!resolvedFeedbackOptions.isUseShakeGesture() || globalFeedbackOptions.isUseShakeGesture()) { + return; + } + final @Nullable Activity activity = getActivity(context); + if (activity == null) { + return; + } + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + shakeDetector = new SentryShakeDetector(options.getLogger()); + final @NotNull WeakReference activityRef = new WeakReference<>(activity); + shakeDetector.start(activity, () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread(() -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }); + final @NotNull Application app = activity.getApplication(); + shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef); + app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.close(); + shakeDetector = null; + } + if (shakeLifecycleCallbacks != null) { + final @Nullable Activity activity = getActivity(getContext()); + if (activity != null) { + activity.getApplication().unregisterActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + shakeLifecycleCallbacks = null; + } + } + + private static @Nullable Activity getActivity(final @NotNull Context context) { + Context current = context; + while (current instanceof ContextWrapper) { + if (current instanceof Activity) { + return (Activity) current; + } + current = ((ContextWrapper) current).getBaseContext(); + } + return null; + } + + private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCallbacks { + private final @NotNull WeakReference activityRef; + + ShakeLifecycleCallbacks(final @NotNull WeakReference activityRef) { + this.activityRef = activityRef; + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.start(activity, () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread(() -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }); + } + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.stop(); + } + } + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) { + if (activity == activityRef.get()) { + stopShakeDetection(); + } + } + + @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 @@ -57,14 +172,7 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.sentry_dialog_user_feedback); setCancelable(isCancelable); - final @NotNull SentryFeedbackOptions feedbackOptions = - new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); - if (configuration != null) { - configuration.configure(getContext(), feedbackOptions); - } - if (configurator != null) { - configurator.configure(feedbackOptions); - } + final @NotNull SentryFeedbackOptions feedbackOptions = resolvedFeedbackOptions; final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 548e5e8ac0d..26f526124b4 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -271,6 +271,7 @@ + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 4c4ef05fb1a..ab95ea5561a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -62,6 +63,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -79,8 +81,10 @@ import io.sentry.MeasurementUnit import io.sentry.Sentry import io.sentry.SentryLogLevel import io.sentry.UpdateStatus +import io.sentry.android.core.SentryUserFeedbackForm import io.sentry.compose.SentryTraced import io.sentry.compose.SentryUserFeedbackButton +import io.sentry.protocol.Feedback import io.sentry.protocol.User import java.io.File import java.io.FileOutputStream @@ -615,8 +619,103 @@ fun UserFeedbackScreen() { } } - // SentryUserFeedbackButton as a special item - item(span = { GridItemSpan(maxLineSpan) }) { SentryUserFeedbackButton(modifier = Modifier) } + // Bring up User Feedback Form from a custom button using the global Sentry.feedback() API + item(span = { GridItemSpan(maxLineSpan) }) { + Button(modifier = Modifier, onClick = { Sentry.feedback().show() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24), + contentDescription = null, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(text = "Report a Bug") + } + } + } + + // Create a SentryUserFeedbackForm programmatically and show it + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.formTitle = "Custom Form" + options.submitButtonLabel = "Send" + options.cancelButtonLabel = "Never mind" + options.messageLabel = "What happened?" + options.messagePlaceholder = "Describe the issue..." + options.isShowBranding = false + options.isNameRequired = true + options.isEmailRequired = true + options.setOnSubmitSuccess { feedback -> + Toast.makeText(activity, "Thanks for the feedback!", Toast.LENGTH_SHORT).show() + } + } + .create() + .show() + }, + ) { + Text(text = "Custom Form (Builder)") + } + } + + // Showcases how to manually show and dismiss a form programmatically + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val form = SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.formTitle = "Quick! You have 2 seconds" + } + .create() + form.show() + Handler(Looper.getMainLooper()).postDelayed({ form.dismiss() }, 2000) + }, + ) { + Text(text = "Auto-dismiss Form (2s)") + } + } + + // Send feedback programmatically without showing a form + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val feedback = Feedback("The app crashed when I tapped the button").apply { + name = "Jane Doe" + contactEmail = "jane@example.com" + url = "https://example.com/page" + } + val eventId = Sentry.feedback().capture(feedback) + Toast.makeText(activity, "Feedback sent: $eventId", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Send Feedback (no form)") + } + } + + // Enable shake-to-show for a specific form instance + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.isUseShakeGesture = true + options.formTitle = "Shake Feedback" + } + .create() + Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Enable Shake-to-Show") + } + } } } From 973955f2d9f72b436664e7cc2e1ccfb55e4bc613 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Apr 2026 20:51:35 +0200 Subject: [PATCH 2/6] docs(changelog): Add per-form shake detection entry Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index beabdfb7f15..bc7ee5d2cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - All deprecated APIs will be removed in the next major version - Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) - It will be removed in the next major version +- Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353)) ### Dependencies From 51209a774382cd162b7e469f19cd1036868ddf39 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Thu, 30 Apr 2026 18:53:01 +0000 Subject: [PATCH 3/6] Format code --- .../android/core/SentryUserFeedbackForm.java | 41 +++++++++++-------- .../io/sentry/samples/android/MainActivity.kt | 26 ++++++------ 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 1ca1bef2123..4e32cd76b2a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -70,16 +70,19 @@ private void maybeStartShakeDetection(final @NotNull Context context) { final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); shakeDetector = new SentryShakeDetector(options.getLogger()); final @NotNull WeakReference activityRef = new WeakReference<>(activity); - shakeDetector.start(activity, () -> { - final @Nullable Activity active = activityRef.get(); - if (active != null && !active.isFinishing() && !active.isDestroyed()) { - active.runOnUiThread(() -> { - if (!active.isFinishing() && !active.isDestroyed()) { - show(); + shakeDetector.start( + activity, + () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); } }); - } - }); final @NotNull Application app = activity.getApplication(); shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef); app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks); @@ -120,16 +123,19 @@ private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCa @Override public void onActivityResumed(final @NotNull Activity activity) { if (activity == activityRef.get() && shakeDetector != null) { - shakeDetector.start(activity, () -> { - final @Nullable Activity active = activityRef.get(); - if (active != null && !active.isFinishing() && !active.isDestroyed()) { - active.runOnUiThread(() -> { - if (!active.isFinishing() && !active.isDestroyed()) { - show(); + shakeDetector.start( + activity, + () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); } }); - } - }); } } @@ -150,10 +156,13 @@ public void onActivityDestroyed(final @NotNull Activity activity) { @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) {} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index ab95ea5561a..e000b54e4cc 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -83,7 +83,6 @@ import io.sentry.SentryLogLevel import io.sentry.UpdateStatus import io.sentry.android.core.SentryUserFeedbackForm import io.sentry.compose.SentryTraced -import io.sentry.compose.SentryUserFeedbackButton import io.sentry.protocol.Feedback import io.sentry.protocol.User import java.io.File @@ -627,7 +626,10 @@ fun UserFeedbackScreen() { horizontalArrangement = Arrangement.Center, ) { Icon( - painter = painterResource(id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24), + painter = + painterResource( + id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24 + ), contentDescription = null, ) Spacer(Modifier.padding(horizontal = 4.dp)) @@ -668,11 +670,10 @@ fun UserFeedbackScreen() { Button( modifier = Modifier, onClick = { - val form = SentryUserFeedbackForm.Builder(activity) - .configurator { options -> - options.formTitle = "Quick! You have 2 seconds" - } - .create() + val form = + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> options.formTitle = "Quick! You have 2 seconds" } + .create() form.show() Handler(Looper.getMainLooper()).postDelayed({ form.dismiss() }, 2000) }, @@ -686,11 +687,12 @@ fun UserFeedbackScreen() { Button( modifier = Modifier, onClick = { - val feedback = Feedback("The app crashed when I tapped the button").apply { - name = "Jane Doe" - contactEmail = "jane@example.com" - url = "https://example.com/page" - } + val feedback = + Feedback("The app crashed when I tapped the button").apply { + name = "Jane Doe" + contactEmail = "jane@example.com" + url = "https://example.com/page" + } val eventId = Sentry.feedback().capture(feedback) Toast.makeText(activity, "Feedback sent: $eventId", Toast.LENGTH_SHORT).show() }, From 109a8a22c0ef42c8e78fe01fecac36e078c56926 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Apr 2026 21:16:34 +0200 Subject: [PATCH 4/6] docs(changelog): Add usage example for per-form shake detection Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7ee5d2cc5..f92dd4ef1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ - Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) - It will be removed in the next major version - Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353)) + ```java + new SentryUserFeedbackForm.Builder(activity) + .configurator(options -> options.setUseShakeGesture(true)) + .create(); + ``` ### Dependencies From 929b4761c4074431e09dccec7af7067d6140b7fb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 30 Apr 2026 21:17:48 +0200 Subject: [PATCH 5/6] docs(changelog): Use Kotlin example and clarify per-screen usage Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f92dd4ef1b4..75481a86715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,11 @@ - Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) - It will be removed in the next major version - Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353)) - ```java - new SentryUserFeedbackForm.Builder(activity) - .configurator(options -> options.setUseShakeGesture(true)) - .create(); + - Useful for enabling shake-to-report on specific screens instead of globally + ```kotlin + SentryUserFeedbackForm.Builder(activity) + .configurator { it.isUseShakeGesture = true } + .create() ``` ### Dependencies From 35ee7a51db3048208174d8b51a36598075dd8d7f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 May 2026 11:44:45 +0200 Subject: [PATCH 6/6] ref(feedback): Extract shared shake listener in SentryUserFeedbackForm Co-Authored-By: Claude Opus 4.6 --- .../android/core/SentryUserFeedbackForm.java | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 4e32cd76b2a..fac5e43263a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -70,19 +70,7 @@ private void maybeStartShakeDetection(final @NotNull Context context) { final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); shakeDetector = new SentryShakeDetector(options.getLogger()); final @NotNull WeakReference activityRef = new WeakReference<>(activity); - shakeDetector.start( - activity, - () -> { - final @Nullable Activity active = activityRef.get(); - if (active != null && !active.isFinishing() && !active.isDestroyed()) { - active.runOnUiThread( - () -> { - if (!active.isFinishing() && !active.isDestroyed()) { - show(); - } - }); - } - }); + shakeDetector.start(activity, shakeListener(activityRef)); final @NotNull Application app = activity.getApplication(); shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef); app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks); @@ -102,6 +90,21 @@ private void stopShakeDetection() { } } + private @NotNull SentryShakeDetector.Listener shakeListener( + final @NotNull WeakReference activityRef) { + return () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }; + } + private static @Nullable Activity getActivity(final @NotNull Context context) { Context current = context; while (current instanceof ContextWrapper) { @@ -123,19 +126,7 @@ private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCa @Override public void onActivityResumed(final @NotNull Activity activity) { if (activity == activityRef.get() && shakeDetector != null) { - shakeDetector.start( - activity, - () -> { - final @Nullable Activity active = activityRef.get(); - if (active != null && !active.isFinishing() && !active.isDestroyed()) { - active.runOnUiThread( - () -> { - if (!active.isFinishing() && !active.isDestroyed()) { - show(); - } - }); - } - }); + shakeDetector.start(activity, shakeListener(activityRef)); } }