diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e51ad73ba8..436e8d65996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ea67ed5bcb7..0bf0d44427d 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -404,6 +404,8 @@ public class io/sentry/android/core/SentryUserFeedbackDialog$Builder { public fun (Landroid/content/Context;I)V public fun (Landroid/content/Context;ILio/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration;)V public fun (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; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 302f4627f20..8a564081ea0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -389,6 +389,9 @@ static void installDefaultIntegrations( options.addIntegration(replay); options.setReplayController(replay); } + options + .getFeedbackOptions() + .setDialogHandler(new SentryAndroidOptions.AndroidUserFeedbackIDialogHandler()); } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 0ac69de3ab9..69ddfe4b0ad 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -1,5 +1,6 @@ package io.sentry.android.core; +import android.app.Activity; import android.app.ActivityManager; import android.app.ApplicationExitInfo; import io.sentry.Hint; @@ -7,12 +8,15 @@ 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; @@ -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(); + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java index 39fff9c7168..542a7027a4f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java @@ -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"); } @@ -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); @@ -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); } @@ -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; @@ -317,6 +331,29 @@ 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. @@ -324,7 +361,8 @@ public Builder( * @return a new instance of {@link SentryUserFeedbackDialog} */ public SentryUserFeedbackDialog create() { - return new SentryUserFeedbackDialog(context, themeResId, configuration); + return new SentryUserFeedbackDialog( + context, themeResId, associatedEventId, configuration, configurator); } } diff --git a/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml b/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml index 52f0a8f044b..4aab5d6c37b 100644 --- a/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml +++ b/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml @@ -1,3 +1,4 @@ + diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index a3248d893af..cd1a7cc26de 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -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 @@ -836,6 +837,12 @@ class AndroidOptionsInitializerTest { assertNull(anrv1Integration) } + @Test + fun `AndroidUserFeedbackIDialogHandler is set as feedback dialog handler`() { + fixture.initSut() + assertIs(fixture.sentryOptions.feedbackOptions.dialogHandler) + } + @Test fun `PersistingScopeObserver is no-op, if scope persistence is disabled`() { fixture.initSut(configureOptions = { isEnableScopePersistence = false }) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt index 265693eede1..bd60859c608 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryUserFeedbackDialogTest.kt @@ -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 @@ -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() @@ -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(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 diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt index 823376b5b8d..ac434535c13 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt @@ -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 @@ -49,7 +50,23 @@ class UserFeedbackUiTest : BaseUiTest() { launchActivity().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().onActivity { Sentry.showUserFeedbackDialog() } + onView(withId(R.id.sentry_dialog_user_feedback_layout)).check(doesNotExist()) + } + + @Test + fun userFeedbackShownViaApi() { + initSentry() + launchActivity().onActivity { Sentry.showUserFeedbackDialog() } + + onView(withId(R.id.sentry_dialog_user_feedback_layout)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) } @Test @@ -461,7 +478,9 @@ class UserFeedbackUiTest : BaseUiTest() { } } - showDialogAndCheck { + val sentryId = SentryId() + + showDialogAndCheck(sentryId) { // Send the feedback fillFormAndSend() } @@ -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 @@ -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() feedbackScenario.onActivity { - dialog = SentryUserFeedbackDialog.Builder(it).create() + dialog = SentryUserFeedbackDialog.Builder(it).associatedEventId(associatedEventId).create() dialog.show() } diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index 59d4828ec7f..3ae9af627b1 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -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; diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 0aa0f3d851c..9624bd018b9 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -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) diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt new file mode 100644 index 00000000000..93460b88893 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryUserFeedbackButton.kt @@ -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) + } + } +} diff --git a/sentry-compose/src/androidMain/res/drawable/sentry_user_feedback_compose_button_logo_24.xml b/sentry-compose/src/androidMain/res/drawable/sentry_user_feedback_compose_button_logo_24.xml new file mode 100644 index 00000000000..39807bd7070 --- /dev/null +++ b/sentry-compose/src/androidMain/res/drawable/sentry_user_feedback_compose_button_logo_24.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 9fd433a1c75..b27da13c770 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -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 @@ -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 = { diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/Init.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/Init.kt index ebf7fb0ee33..5061714d5ed 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/Init.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/Init.kt @@ -39,4 +39,5 @@ fun initForTest() { fun applyTestOptions(options: SentryOptions) { options.shutdownTimeoutMillis = 0 + options.sessionFlushTimeoutMillis = 0 } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9ce63363e95..98092b05910 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2587,6 +2587,9 @@ public final class io/sentry/Sentry { public static fun setTag (Ljava/lang/String;Ljava/lang/String;)V public static fun setTransaction (Ljava/lang/String;)V public static fun setUser (Lio/sentry/protocol/User;)V + public static fun showUserFeedbackDialog ()V + public static fun showUserFeedbackDialog (Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V + public static fun showUserFeedbackDialog (Lio/sentry/protocol/SentryId;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V public static fun startProfiler ()V public static fun startSession ()V public static fun startTransaction (Lio/sentry/TransactionContext;)Lio/sentry/ITransaction; @@ -2961,9 +2964,10 @@ public final class io/sentry/SentryExecutorService : io/sentry/ISentryExecutorSe } public final class io/sentry/SentryFeedbackOptions { - public fun ()V + public fun (Lio/sentry/SentryFeedbackOptions$IDialogHandler;)V public fun (Lio/sentry/SentryFeedbackOptions;)V public fun getCancelButtonLabel ()Ljava/lang/CharSequence; + public fun getDialogHandler ()Lio/sentry/SentryFeedbackOptions$IDialogHandler; public fun getEmailLabel ()Ljava/lang/CharSequence; public fun getEmailPlaceholder ()Ljava/lang/CharSequence; public fun getFormTitle ()Ljava/lang/CharSequence; @@ -2985,6 +2989,7 @@ public final class io/sentry/SentryFeedbackOptions { public fun isShowName ()Z public fun isUseSentryUser ()Z public fun setCancelButtonLabel (Ljava/lang/CharSequence;)V + public fun setDialogHandler (Lio/sentry/SentryFeedbackOptions$IDialogHandler;)V public fun setEmailLabel (Ljava/lang/CharSequence;)V public fun setEmailPlaceholder (Ljava/lang/CharSequence;)V public fun setEmailRequired (Z)V @@ -3008,6 +3013,14 @@ public final class io/sentry/SentryFeedbackOptions { public fun toString ()Ljava/lang/String; } +public abstract interface class io/sentry/SentryFeedbackOptions$IDialogHandler { + public abstract fun showDialog (Lio/sentry/protocol/SentryId;Lio/sentry/SentryFeedbackOptions$OptionsConfigurator;)V +} + +public abstract interface class io/sentry/SentryFeedbackOptions$OptionsConfigurator { + public abstract fun configure (Lio/sentry/SentryFeedbackOptions;)V +} + public abstract interface class io/sentry/SentryFeedbackOptions$SentryFeedbackCallback { public abstract fun call (Lio/sentry/protocol/Feedback;)V } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 0596f8f2ffb..f24ecab9e78 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1289,4 +1289,20 @@ public static ILoggerApi logger() { public static IReplayApi replay() { return getCurrentScopes().getScope().getOptions().getReplayController(); } + + public static void showUserFeedbackDialog() { + showUserFeedbackDialog(null); + } + + public static void showUserFeedbackDialog( + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + showUserFeedbackDialog(null, configurator); + } + + public static void showUserFeedbackDialog( + final @Nullable SentryId associatedEventId, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { + final @NotNull SentryOptions options = getCurrentScopes().getOptions(); + options.getFeedbackOptions().getDialogHandler().showDialog(associatedEventId, configurator); + } } diff --git a/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java b/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java index 0d821b139ba..77e0741f8d6 100644 --- a/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java +++ b/sentry/src/main/java/io/sentry/SentryFeedbackOptions.java @@ -1,11 +1,11 @@ package io.sentry; import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -@ApiStatus.Internal public final class SentryFeedbackOptions { // User and Form /** Requires the name field on the feedback form to be filled in. Defaults to false. */ @@ -83,12 +83,16 @@ public final class SentryFeedbackOptions { private @Nullable Runnable onFormClose; /** Callback called when feedback is successfully submitted via the prepared form. */ - private @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess; + private @Nullable SentryFeedbackCallback onSubmitSuccess; /** Callback called when there is an error submitting feedback via the prepared form. */ private @Nullable SentryFeedbackCallback onSubmitError; - public SentryFeedbackOptions() {} + private @NotNull IDialogHandler iDialogHandler; + + public SentryFeedbackOptions(@NotNull IDialogHandler iDialogHandler) { + this.iDialogHandler = iDialogHandler; + } /** Creates a copy of the passed {@link SentryFeedbackOptions}. */ public SentryFeedbackOptions(final @NotNull SentryFeedbackOptions other) { @@ -113,6 +117,7 @@ public SentryFeedbackOptions(final @NotNull SentryFeedbackOptions other) { this.onFormClose = other.onFormClose; this.onSubmitSuccess = other.onSubmitSuccess; this.onSubmitError = other.onSubmitError; + this.iDialogHandler = other.iDialogHandler; } /** @@ -507,6 +512,26 @@ public void setOnSubmitError(final @Nullable SentryFeedbackCallback onSubmitErro this.onSubmitError = onSubmitError; } + /** + * Sets the dialog handler to be used to show the feedback form. + * + * @param iDialogHandler the dialog handler to be used to show the feedback form + */ + @ApiStatus.Internal + public void setDialogHandler(final @NotNull IDialogHandler iDialogHandler) { + this.iDialogHandler = iDialogHandler; + } + + /** + * Gets the dialog handler to be used to show the feedback form. + * + * @return the dialog handler to be used to show the feedback form + */ + @ApiStatus.Internal + public @NotNull IDialogHandler getDialogHandler() { + return iDialogHandler; + } + @Override public String toString() { return "SentryFeedbackOptions{" @@ -558,4 +583,22 @@ public String toString() { public interface SentryFeedbackCallback { void call(final @NotNull Feedback feedback); } + + @ApiStatus.Internal + public interface IDialogHandler { + void showDialog( + final @Nullable SentryId associatedEventId, + final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator); + } + + /** Configuration callback for feedback options. */ + public interface OptionsConfigurator { + + /** + * configure the feedback options + * + * @param options the feedback options + */ + void configure(final @NotNull SentryFeedbackOptions options); + } } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 4b7bd4d0e53..1ca089d7885 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3063,7 +3063,11 @@ private SentryOptions(final boolean empty) { final @NotNull SdkVersion sdkVersion = createSdkVersion(); experimental = new ExperimentalOptions(empty, sdkVersion); sessionReplay = new SentryReplayOptions(empty, sdkVersion); - feedbackOptions = new SentryFeedbackOptions(); + feedbackOptions = + new SentryFeedbackOptions( + (associatedEventId, configurator) -> + logger.log(SentryLevel.WARNING, "showDialog() can only be called in Android.")); + if (!empty) { setSpanFactory(SpanFactoryFactory.create(new LoadClass(), NoOpLogger.getInstance())); // SentryExecutorService should be initialized before any diff --git a/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt index 410be6aa73d..a50aff02dc5 100644 --- a/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryFeedbackOptionsTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.SentryFeedbackOptions.IDialogHandler import kotlin.test.Test import kotlin.test.assertEquals import org.mockito.kotlin.mock @@ -7,7 +8,7 @@ import org.mockito.kotlin.mock class SentryFeedbackOptionsTest { @Test fun `feedback options is initialized with default values`() { - val options = SentryFeedbackOptions() + val options = SentryFeedbackOptions(mock()) assertEquals(false, options.isNameRequired) assertEquals(true, options.isShowName) assertEquals(false, options.isEmailRequired) @@ -34,7 +35,7 @@ class SentryFeedbackOptionsTest { @Test fun `feedback options copy constructor`() { val options = - SentryFeedbackOptions().apply { + SentryFeedbackOptions(mock()).apply { isNameRequired = true isShowName = false isEmailRequired = true @@ -79,5 +80,6 @@ class SentryFeedbackOptionsTest { assertEquals(options.onFormClose, optionsCopy.onFormClose) assertEquals(options.onSubmitSuccess, optionsCopy.onSubmitSuccess) assertEquals(options.onSubmitError, optionsCopy.onSubmitError) + assertEquals(options.dialogHandler, optionsCopy.dialogHandler) } } diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index c43ad1513ed..d0983db8258 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -13,7 +13,9 @@ import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify class SentryOptionsTest { @Test @@ -816,6 +818,18 @@ class SentryOptionsTest { assertTrue(options.tags.isEmpty()) } + @Test + fun `feedback dialog handler logs a warning`() { + val logger = mock() + val options = + SentryOptions.empty().apply { + setLogger(logger) + isDebug = true + } + options.feedbackOptions.dialogHandler.showDialog(mock(), mock()) + verify(logger).log(eq(SentryLevel.WARNING), eq("showDialog() can only be called in Android.")) + } + @Test fun `autoTransactionDeadlineTimeoutMillis option defaults to 30000`() { val options = SentryOptions.empty() diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 6877a3bdebb..87f4ce17c00 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.SentryFeedbackOptions.IDialogHandler import io.sentry.SentryOptions.ProfilesSamplerCallback import io.sentry.SentryOptions.TracesSamplerCallback import io.sentry.backpressure.BackpressureMonitor @@ -131,7 +132,7 @@ class SentryTest { throw IllegalStateException("bad integration") } - Sentry.init { + initForTest { it.dsn = dsn it.integrations.clear() it.integrations.add(badIntegration) @@ -391,7 +392,7 @@ class SentryTest { fun `profilingTracesDirPath should be created and cleared at initialization when continuous profiling is enabled`() { val tempPath = getTempPath() var sentryOptions: SentryOptions? = null - Sentry.init { + initForTest { it.dsn = dsn it.profileSessionSampleRate = 1.0 it.cacheDirPath = tempPath @@ -406,7 +407,7 @@ class SentryTest { fun `profilingTracesDirPath should not be created when no profiling is enabled`() { val tempPath = getTempPath() var sentryOptions: SentryOptions? = null - Sentry.init { + initForTest { it.dsn = dsn it.profileSessionSampleRate = 0.0 it.cacheDirPath = tempPath @@ -923,6 +924,7 @@ class SentryTest { // the scopes is already initialized scopes.startSession() } + it.sessionFlushTimeoutMillis = 100 } assertFalse(previousSessionFile.exists()) @@ -1193,7 +1195,7 @@ class SentryTest { fun `init calls samplers if isStartProfilerOnAppStart is true`() { val mockSampleTracer = mock() val mockProfilesSampler = mock() - Sentry.init { + initForTest { it.dsn = dsn it.tracesSampleRate = 1.0 it.isStartProfilerOnAppStart = true @@ -1306,7 +1308,7 @@ class SentryTest { val appStartProfilingConfigFile = File(path, "app_start_profiling_config") appStartProfilingConfigFile.createNewFile() assertTrue(appStartProfilingConfigFile.exists()) - Sentry.init { + initForTest { it.dsn = dsn it.cacheDirPath = path it.isEnableAppStartProfiling = false @@ -1421,7 +1423,7 @@ class SentryTest { @Test fun `startProfiler starts the continuous profiler`() { val profiler = mock() - Sentry.init { + initForTest { it.dsn = dsn it.setContinuousProfiler(profiler) it.profileSessionSampleRate = 1.0 @@ -1433,7 +1435,7 @@ class SentryTest { @Test fun `startProfiler is ignored when continuous profiling is disabled`() { val profiler = mock() - Sentry.init { + initForTest { it.dsn = dsn it.setContinuousProfiler(profiler) it.profilesSampleRate = 1.0 @@ -1446,7 +1448,7 @@ class SentryTest { fun `startProfiler is ignored when profile lifecycle is TRACE`() { val profiler = mock() val logger = mock() - Sentry.init { + initForTest { it.dsn = dsn it.setContinuousProfiler(profiler) it.profileSessionSampleRate = 1.0 @@ -1467,7 +1469,7 @@ class SentryTest { @Test fun `stopProfiler stops the continuous profiler`() { val profiler = mock() - Sentry.init { + initForTest { it.dsn = dsn it.setContinuousProfiler(profiler) it.profileSessionSampleRate = 1.0 @@ -1479,7 +1481,7 @@ class SentryTest { @Test fun `stopProfiler is ignored when continuous profiling is disabled`() { val profiler = mock() - Sentry.init { + initForTest { it.dsn = dsn it.setContinuousProfiler(profiler) it.profilesSampleRate = 1.0 @@ -1491,7 +1493,7 @@ class SentryTest { @Test fun `replay debug masking is forwarded to replay controller`() { val replayController = mock() - Sentry.init { + initForTest { it.dsn = dsn it.setReplayController(replayController) } @@ -1502,6 +1504,42 @@ class SentryTest { verify(replayController).disableDebugMaskingOverlay() } + @Test + fun `showUserFeedbackDialog forwards to feedbackOptions_dialogHandler`() { + val mockDialogHandler = mock() + initForTest { + it.dsn = dsn + it.feedbackOptions.dialogHandler = mockDialogHandler + } + Sentry.showUserFeedbackDialog() + verify(mockDialogHandler).showDialog(eq(null), eq(null)) + } + + @Test + fun `showUserFeedbackDialog forwards to feedbackOptions_dialogHandler with configurator`() { + val mockDialogHandler = mock() + val configurator = mock() + initForTest { + it.dsn = dsn + it.feedbackOptions.dialogHandler = mockDialogHandler + } + Sentry.showUserFeedbackDialog(configurator) + verify(mockDialogHandler).showDialog(eq(null), eq(configurator)) + } + + @Test + fun `showUserFeedbackDialog forwards to feedbackOptions_dialogHandler with associatedEventId and configurator`() { + val mockDialogHandler = mock() + val configurator = mock() + val associatedEventId = SentryId() + initForTest { + it.dsn = dsn + it.feedbackOptions.dialogHandler = mockDialogHandler + } + Sentry.showUserFeedbackDialog(associatedEventId, configurator) + verify(mockDialogHandler).showDialog(eq(associatedEventId), eq(configurator)) + } + private class InMemoryOptionsObserver : IOptionsObserver { var release: String? = null private set