Skip to content

Add Compose user feedback button #4559

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 23, 2025
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features

- Add `SentryUserFeedbackButton` Composable ([#4559](https://github.com/getsentry/sentry-java/pull/4559))
- Also added `Sentry.showUserFeedbackDialog` static method
- Add deadlineTimeout option ([#4555](https://github.com/getsentry/sentry-java/pull/4555))

### Fixes
Expand Down
2 changes: 2 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ public class io/sentry/android/core/SentryUserFeedbackDialog$Builder {
public fun <init> (Landroid/content/Context;I)V
public fun <init> (Landroid/content/Context;ILio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V
public fun <init> (Landroid/content/Context;Lio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V
public fun associatedEventId (Lio/sentry/protocol/SentryId;)Lio/sentry/android/core/SentryUserFeedbackDialog$Builder;
public fun configurator (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)Lio/sentry/android/core/SentryUserFeedbackDialog$Builder;
public fun create ()Lio/sentry/android/core/SentryUserFeedbackDialog;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ static void installDefaultIntegrations(
options.addIntegration(replay);
options.setReplayController(replay);
}
options
.getFeedbackOptions()
.setDialogHandler(new SentryAndroidOptions.AndroidUserFeedbackIDialogHandler());
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
package io.sentry.android.core;

import android.app.Activity;
import android.app.ActivityManager;
import android.app.ApplicationExitInfo;
import io.sentry.Hint;
import io.sentry.IScope;
import io.sentry.ISpan;
import io.sentry.Sentry;
import io.sentry.SentryEvent;
import io.sentry.SentryFeedbackOptions;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.SpanStatus;
import io.sentry.android.core.internal.util.RootChecker;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.protocol.Mechanism;
import io.sentry.protocol.SdkVersion;
import io.sentry.protocol.SentryId;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand Down Expand Up @@ -609,4 +613,29 @@ public boolean isEnableAutoTraceIdGeneration() {
public void setEnableAutoTraceIdGeneration(final boolean enableAutoTraceIdGeneration) {
this.enableAutoTraceIdGeneration = enableAutoTraceIdGeneration;
}

static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler {
@Override
public void showDialog(
final @Nullable SentryId associatedEventId,
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
if (activity == null) {
Sentry.getCurrentScopes()
.getOptions()
.getLogger()
.log(
SentryLevel.ERROR,
"Cannot show user feedback dialog, no activity is available. "
+ "Make sure to call SentryAndroid.init() in your Application.onCreate() method.");
return;
}

new SentryUserFeedbackDialog.Builder(activity)
.associatedEventId(associatedEventId)
.configurator(configurator)
.create()
.show();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,22 @@ public final class SentryUserFeedbackDialog extends AlertDialog {

private boolean isCancelable = false;
private @Nullable SentryId currentReplayId;
private final @Nullable SentryId associatedEventId;
private @Nullable OnDismissListener delegate;

private final @Nullable OptionsConfiguration configuration;
private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator;

SentryUserFeedbackDialog(
final @NotNull Context context,
final int themeResId,
final @Nullable OptionsConfiguration configuration) {
final @Nullable SentryId associatedEventId,
final @Nullable OptionsConfiguration configuration,
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
super(context, themeResId);
this.associatedEventId = associatedEventId;
this.configuration = configuration;
this.configurator = configurator;
SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget");
}

Expand All @@ -56,6 +62,9 @@ protected void onCreate(Bundle savedInstanceState) {
if (configuration != null) {
configuration.configure(getContext(), feedbackOptions);
}
if (configurator != null) {
configurator.configure(feedbackOptions);
}
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);
Expand Down Expand Up @@ -145,6 +154,9 @@ protected void onCreate(Bundle savedInstanceState) {
final @NotNull Feedback feedback = new Feedback(message);
feedback.setName(name);
feedback.setContactEmail(email);
if (associatedEventId != null) {
feedback.setAssociatedEventId(associatedEventId);
}
if (currentReplayId != null) {
feedback.setReplayId(currentReplayId);
}
Expand Down Expand Up @@ -226,6 +238,8 @@ public void show() {
public static class Builder {

@Nullable OptionsConfiguration configuration;
@Nullable SentryFeedbackOptions.OptionsConfigurator configurator;
@Nullable SentryId associatedEventId;
final @NotNull Context context;
final int themeResId;

Expand Down Expand Up @@ -317,14 +331,38 @@ public Builder(
this.configuration = configuration;
}

/**
* Sets the configuration for the feedback options.
*
* @param configurator the configuration for the feedback options, can be {@code null} to use
* the global feedback options.
*/
public Builder configurator(
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
this.configurator = configurator;
return this;
}

/**
* Sets the associated event ID for the feedback.
*
* @param associatedEventId the associated event ID for the feedback, can be {@code null} to
* avoid associating the feedback to an event.
*/
public Builder associatedEventId(final @Nullable SentryId associatedEventId) {
this.associatedEventId = associatedEventId;
return this;
}

/**
* Builds a new {@link SentryUserFeedbackDialog} with the specified context, theme, and
* configuration.
*
* @return a new instance of {@link SentryUserFeedbackDialog}
*/
public SentryUserFeedbackDialog create() {
return new SentryUserFeedbackDialog(context, themeResId, configuration);
return new SentryUserFeedbackDialog(
context, themeResId, associatedEventId, configuration, configurator);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<!-- Taken from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/resources/images/material/icons/materialicons/campaign/baseline_campaign_24.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?android:attr/colorForeground" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">

<path android:fillColor="?android:attr/colorForeground" android:pathData="M18,11v2h4v-2h-4zM16,17.61c0.96,0.71 2.21,1.65 3.2,2.39 0.4,-0.53 0.8,-1.07 1.2,-1.6 -0.99,-0.74 -2.24,-1.68 -3.2,-2.4 -0.4,0.54 -0.8,1.08 -1.2,1.61zM20.4,5.6c-0.4,-0.53 -0.8,-1.07 -1.2,-1.6 -0.99,0.74 -2.24,1.68 -3.2,2.4 0.4,0.53 0.8,1.07 1.2,1.6 0.96,-0.72 2.21,-1.65 3.2,-2.4zM4,9c-1.1,0 -2,0.9 -2,2v2c0,1.1 0.9,2 2,2h1v4h2v-4h1l5,3L13,6L8,9L4,9zM15.5,12c0,-1.33 -0.58,-2.53 -1.5,-3.35v6.69c0.92,-0.81 1.5,-2.01 1.5,-3.34z"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import io.sentry.MainEventProcessor
import io.sentry.NoOpContinuousProfiler
import io.sentry.NoOpTransactionProfiler
import io.sentry.SentryOptions
import io.sentry.android.core.SentryAndroidOptions.AndroidUserFeedbackIDialogHandler
import io.sentry.android.core.cache.AndroidEnvelopeCache
import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader
import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator
Expand Down Expand Up @@ -836,6 +837,12 @@ class AndroidOptionsInitializerTest {
assertNull(anrv1Integration)
}

@Test
fun `AndroidUserFeedbackIDialogHandler is set as feedback dialog handler`() {
fixture.initSut()
assertIs<AndroidUserFeedbackIDialogHandler>(fixture.sentryOptions.feedbackOptions.dialogHandler)
}

@Test
fun `PersistingScopeObserver is no-op, if scope persistence is disabled`() {
fixture.initSut(configureOptions = { isEnableScopePersistence = false })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import io.sentry.IScope
import io.sentry.IScopes
import io.sentry.ReplayController
import io.sentry.Sentry
import io.sentry.SentryFeedbackOptions
import io.sentry.SentryLevel
import io.sentry.protocol.SentryId
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
Expand Down Expand Up @@ -52,8 +54,11 @@ class SentryUserFeedbackDialogTest {
}

fun getSut(
configuration: SentryUserFeedbackDialog.OptionsConfiguration? = null
): SentryUserFeedbackDialog = SentryUserFeedbackDialog(application, 0, configuration)
associatedEventId: SentryId? = null,
configuration: SentryUserFeedbackDialog.OptionsConfiguration? = null,
configurator: SentryFeedbackOptions.OptionsConfigurator? = null,
): SentryUserFeedbackDialog =
SentryUserFeedbackDialog(application, 0, associatedEventId, configuration, configurator)
}

private val fixture = Fixture()
Expand Down Expand Up @@ -98,7 +103,23 @@ class SentryUserFeedbackDialogTest {
@Test
fun `when configuration is passed, it is applied to the current dialog only`() {
fixture.options.isEnabled = true
val sut = fixture.getSut { context, options -> options.formTitle = "custom title" }
val sut =
fixture.getSut(configuration = { context, options -> options.formTitle = "custom title" })
assertNotEquals("custom title", fixture.options.feedbackOptions.formTitle)
sut.show()
// After showing the dialog, the title should be set
assertEquals(
"custom title",
sut.findViewById<TextView>(R.id.sentry_dialog_user_feedback_title).text,
)
// And the original options should not be modified
assertNotEquals("custom title", fixture.options.feedbackOptions.formTitle)
}

@Test
fun `when configurator is passed, it is applied to the current dialog only`() {
fixture.options.isEnabled = true
val sut = fixture.getSut(configurator = { options -> options.formTitle = "custom title" })
assertNotEquals("custom title", fixture.options.feedbackOptions.formTitle)
sut.show()
// After showing the dialog, the title should be set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import io.sentry.android.core.R
import io.sentry.android.core.SentryUserFeedbackButton
import io.sentry.android.core.SentryUserFeedbackDialog
import io.sentry.assertEnvelopeFeedback
import io.sentry.protocol.SentryId
import io.sentry.protocol.User
import io.sentry.test.getProperty
import kotlin.test.Test
Expand All @@ -49,7 +50,23 @@ class UserFeedbackUiTest : BaseUiTest() {
launchActivity<EmptyActivity>().onActivity {
SentryUserFeedbackDialog.Builder(it).create().show()
}
onView(withId(R.id.sentry_dialog_user_feedback_title)).check(doesNotExist())
onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist())
}

@Test
fun userFeedbackNotShownWhenSdkDisabledViaApi() {
launchActivity<EmptyActivity>().onActivity { Sentry.showUserFeedbackDialog() }
onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist())
}

@Test
fun userFeedbackShownViaApi() {
initSentry()
launchActivity<EmptyActivity>().onActivity { Sentry.showUserFeedbackDialog() }

onView(withId(R.id.sentry_dialog_user_feedback_layout))
.inRoot(isDialog())
.check(matches(isDisplayed()))
}

@Test
Expand Down Expand Up @@ -461,7 +478,9 @@ class UserFeedbackUiTest : BaseUiTest() {
}
}

showDialogAndCheck {
val sentryId = SentryId()

showDialogAndCheck(sentryId) {
// Send the feedback
fillFormAndSend()
}
Expand All @@ -481,6 +500,7 @@ class UserFeedbackUiTest : BaseUiTest() {
assertEquals("Description filled", feedback.message)
// The screen name should be set in the url
assertEquals("io.sentry.uitest.android.EmptyActivity", feedback.url)
assertEquals(sentryId, feedback.associatedEventId)

if (enableReplay) {
// The current replay should be set in the replayId
Expand Down Expand Up @@ -613,11 +633,14 @@ class UserFeedbackUiTest : BaseUiTest() {
onView(withId(R.id.sentry_dialog_user_feedback_btn_send)).perform(click())
}

private fun showDialogAndCheck(checker: (dialog: SentryUserFeedbackDialog) -> Unit = {}) {
private fun showDialogAndCheck(
associatedEventId: SentryId? = null,
checker: (dialog: SentryUserFeedbackDialog) -> Unit = {},
) {
lateinit var dialog: SentryUserFeedbackDialog
val feedbackScenario = launchActivity<EmptyActivity>()
feedbackScenario.onActivity {
dialog = SentryUserFeedbackDialog.Builder(it).create()
dialog = SentryUserFeedbackDialog.Builder(it).associatedEventId(associatedEventId).create()
dialog.show()
}

Expand Down
4 changes: 4 additions & 0 deletions sentry-compose/api/android/sentry-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public final class io/sentry/compose/SentryNavigationIntegrationKt {
public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;ZZLandroidx/compose/runtime/Composer;II)Landroidx/navigation/NavHostController;
}

public final class io/sentry/compose/SentryUserFeedbackButtonKt {
public static final fun SentryUserFeedbackButton (Landroidx/compose/ui/Modifier;Ljava/lang/String;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;Landroidx/compose/runtime/Composer;II)V
}

public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator {
public static final field $stable I
public static final field Companion Lio/sentry/compose/gestures/ComposeGestureTargetLocator$Companion;
Expand Down
1 change: 1 addition & 0 deletions sentry-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ kotlin {
dependencies {
api(projects.sentry)
api(projects.sentryAndroidNavigation)
implementation(libs.androidx.compose.material3)

compileOnly(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.common.java8)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package io.sentry.compose

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import io.sentry.Sentry
import io.sentry.SentryFeedbackOptions

@Composable
public fun SentryUserFeedbackButton(
modifier: Modifier = Modifier,
text: String = "Report a Bug",
configurator: SentryFeedbackOptions.OptionsConfigurator? = null,
) {
Button(modifier = modifier, onClick = { Sentry.showUserFeedbackDialog(configurator) }) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
painter = painterResource(id = R.drawable.sentry_user_feedback_compose_button_logo_24),
contentDescription = null,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text = text)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- Taken from https://cs.android.com/android-studio/platform/tools/adt/idea/+/mirror-goog-studio-main:android/resources/images/material/icons/materialicons/campaign/baseline_campaign_24.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">

<path android:fillColor="?android:attr/colorForeground" android:pathData="M18,11v2h4v-2h-4zM16,17.61c0.96,0.71 2.21,1.65 3.2,2.39 0.4,-0.53 0.8,-1.07 1.2,-1.6 -0.99,-0.74 -2.24,-1.68 -3.2,-2.4 -0.4,0.54 -0.8,1.08 -1.2,1.61zM20.4,5.6c-0.4,-0.53 -0.8,-1.07 -1.2,-1.6 -0.99,0.74 -2.24,1.68 -3.2,2.4 0.4,0.53 0.8,1.07 1.2,1.6 0.96,-0.72 2.21,-1.65 3.2,-2.4zM4,9c-1.1,0 -2,0.9 -2,2v2c0,1.1 0.9,2 2,2h1v4h2v-4h1l5,3L13,6L8,9L4,9zM15.5,12c0,-1.33 -0.58,-2.53 -1.5,-3.35v6.69c0.92,-0.81 1.5,-2.01 1.5,-3.34z"/>

</vector>
Loading
Loading