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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Add Compose user feedback button ([#4559](https://github.com/getsentry/sentry-java/pull/4559))

### Fixes

- Allow multiple UncaughtExceptionHandlerIntegrations to be active at the same time ([#4462](https://github.com/getsentry/sentry-java/pull/4462))
Expand Down
3 changes: 2 additions & 1 deletion sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@ public final class io/sentry/android/core/SentryUserFeedbackDialog : android/app
public class io/sentry/android/core/SentryUserFeedbackDialog$Builder {
public fun <init> (Landroid/content/Context;)V
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;ILio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V
public fun <init> (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V
public fun <init> (Landroid/content/Context;Lio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V
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,12 +1,15 @@
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;
Expand Down Expand Up @@ -609,4 +612,23 @@ public boolean isEnableAutoTraceIdGeneration() {
public void setEnableAutoTraceIdGeneration(final boolean enableAutoTraceIdGeneration) {
this.enableAutoTraceIdGeneration = enableAutoTraceIdGeneration;
}

static class AndroidUserFeedbackIDialogHandler implements SentryFeedbackOptions.IDialogHandler {
@Override
public void showDialog(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, configurator).create().show();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ public final class SentryUserFeedbackDialog extends AlertDialog {
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 OptionsConfiguration configuration,
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
super(context, themeResId);
this.configuration = configuration;
this.configurator = configurator;
SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget");
}

Expand All @@ -56,6 +59,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 @@ -226,6 +232,7 @@ public void show() {
public static class Builder {

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

Expand Down Expand Up @@ -264,7 +271,7 @@ public Builder(final @NotNull Context context) {
* {@code 0} to use the parent {@code context}'s default alert dialog theme
*/
public Builder(Context context, int themeResId) {
this(context, themeResId, null);
this(context, themeResId, null, null);
}

/**
Expand All @@ -281,7 +288,25 @@ public Builder(Context context, int themeResId) {
*/
public Builder(
final @NotNull Context context, final @Nullable OptionsConfiguration configuration) {
this(context, 0, configuration);
this(context, 0, configuration, null);
}

/**
* Creates a builder for a {@link SentryUserFeedbackDialog} that uses the default alert dialog
* theme. The {@code configuration} can be used to configure the feedback options for this
* specific dialog.
*
* <p>The default alert dialog theme is defined by {@link android.R.attr#alertDialogTheme}
* within the parent {@code context}'s theme.
*
* @param context the parent context
* @param configurator the configuration for the feedback options, can be {@code null} to use
* the global feedback options.
*/
public Builder(
final @NotNull Context context,
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
this(context, 0, null, configurator);
}

/**
Expand Down Expand Up @@ -311,10 +336,12 @@ public Builder(
public Builder(
final @NotNull Context context,
final int themeResId,
final @Nullable OptionsConfiguration configuration) {
final @Nullable OptionsConfiguration configuration,
final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) {
this.context = context;
this.themeResId = themeResId;
this.configuration = configuration;
this.configurator = configurator;
}

/**
Expand All @@ -324,7 +351,7 @@ public Builder(
* @return a new instance of {@link SentryUserFeedbackDialog}
*/
public SentryUserFeedbackDialog create() {
return new SentryUserFeedbackDialog(context, themeResId, configuration);
return new SentryUserFeedbackDialog(context, themeResId, configuration, configurator);
}
}

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,6 +9,7 @@ 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 kotlin.test.AfterTest
import kotlin.test.BeforeTest
Expand Down Expand Up @@ -52,8 +53,10 @@ class SentryUserFeedbackDialogTest {
}

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

private val fixture = Fixture()
Expand Down Expand Up @@ -98,7 +101,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 @@ -49,7 +49,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
11 changes: 11 additions & 0 deletions sentry-compose/api/android/sentry-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ public final class io/sentry/compose/BuildConfig {
public fun <init> ()V
}

public final class io/sentry/compose/ComposableSingletons$SentryUserFeedbackButtonKt {
public static final field INSTANCE Lio/sentry/compose/ComposableSingletons$SentryUserFeedbackButtonKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public fun <init> ()V
public final fun getLambda-1$sentry_compose_release ()Lkotlin/jvm/functions/Function3;
}

public final class io/sentry/compose/SentryComposeHelperKt {
public static final fun boundsInWindow (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;)Landroidx/compose/ui/geometry/Rect;
}
Expand All @@ -26,6 +33,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;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,36 @@
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,
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 = "Vector Icon",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text = "Report a Bug")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<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>
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import androidx.navigation.navArgument
import coil.compose.AsyncImage
import io.sentry.android.replay.sentryReplayUnmask
import io.sentry.compose.SentryTraced
import io.sentry.compose.SentryUserFeedbackButton
import io.sentry.compose.withSentryObservableEffect
import io.sentry.samples.android.GithubAPI
import io.sentry.samples.android.R as IR
Expand Down Expand Up @@ -108,6 +109,12 @@ fun Landing(navigateGithub: () -> Unit, navigateGithubWithArgs: () -> Unit) {
Text("Show Dialog", modifier = Modifier.sentryReplayUnmask())
}
}
SentryTraced(tag = "button_dialog") {
SentryUserFeedbackButton(modifier = Modifier.padding(top = 32.dp)) { options ->
options.formTitle = "Report a Bug???"
options.messageLabel = "Please provide details about the bug you encountered."
}
}
if (showDialog) {
BasicAlertDialog(
onDismissRequest = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ fun initForTest() {

fun applyTestOptions(options: SentryOptions) {
options.shutdownTimeoutMillis = 0
options.sessionFlushTimeoutMillis = 0
}
Loading
Loading