diff --git a/CHANGELOG.md b/CHANGELOG.md index a0774abddf..71abe457e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Features + +- Add New User Feedback form ([#4384](https://github.com/getsentry/sentry-java/pull/4384)) + - We now introduce SentryUserFeedbackDialog, which extends AlertDialog, inheriting the show() and cancel() methods, among others. + To use it, just instantiate it and call show() on the instance (Sentry must be previously initialized). + For customization options, please check the [User Feedback documentation](https://docs.sentry.io/platforms/android/user-feedback/configuration/). + ```java + import io.sentry.android.core.SentryUserFeedbackDialog; + + new SentryUserFeedbackDialog.Builder(context).create().show(); + ``` + ```kotlin + import io.sentry.android.core.SentryUserFeedbackDialog + + SentryUserFeedbackDialog.Builder(context).create().show() + ``` + ## 8.13.3 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index afb6b6a175..352f8111c6 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -385,6 +385,24 @@ public final class io/sentry/android/core/SentryPerformanceProvider { public fun shutdown ()V } +public final class io/sentry/android/core/SentryUserFeedbackDialog : android/app/AlertDialog { + public fun setCancelable (Z)V + public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V + public fun show ()V +} + +public class io/sentry/android/core/SentryUserFeedbackDialog$Builder { + public fun (Landroid/content/Context;)V + 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 create ()Lio/sentry/android/core/SentryUserFeedbackDialog; +} + +public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$OptionsConfiguration { + public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V +} + public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 2427b49675..17fbe2d39c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -6,6 +6,7 @@ import io.sentry.ILogger; import io.sentry.InitPriority; import io.sentry.ProfileLifecycle; +import io.sentry.SentryFeedbackOptions; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.protocol.SdkVersion; @@ -126,6 +127,18 @@ final class ManifestMetadataReader { static final String ENABLE_AUTO_TRACE_ID_GENERATION = "io.sentry.traces.enable-auto-id-generation"; + static final String FEEDBACK_NAME_REQUIRED = "io.sentry.feedback.is-name-required"; + + static final String FEEDBACK_SHOW_NAME = "io.sentry.feedback.show-name"; + + static final String FEEDBACK_EMAIL_REQUIRED = "io.sentry.feedback.is-email-required"; + + static final String FEEDBACK_SHOW_EMAIL = "io.sentry.feedback.show-email"; + + static final String FEEDBACK_USE_SENTRY_USER = "io.sentry.feedback.use-sentry-user"; + + static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -477,6 +490,21 @@ static void applyMetadata( options .getLogs() .setEnabled(readBool(metadata, logger, ENABLE_LOGS, options.getLogs().isEnabled())); + + final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); + feedbackOptions.setNameRequired( + readBool(metadata, logger, FEEDBACK_NAME_REQUIRED, feedbackOptions.isNameRequired())); + feedbackOptions.setShowName( + readBool(metadata, logger, FEEDBACK_SHOW_NAME, feedbackOptions.isShowName())); + feedbackOptions.setEmailRequired( + readBool(metadata, logger, FEEDBACK_EMAIL_REQUIRED, feedbackOptions.isEmailRequired())); + feedbackOptions.setShowEmail( + readBool(metadata, logger, FEEDBACK_SHOW_EMAIL, feedbackOptions.isShowEmail())); + feedbackOptions.setUseSentryUser( + readBool( + metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser())); + feedbackOptions.setShowBranding( + readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding())); } options .getLogger() 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 new file mode 100644 index 0000000000..a188d8a371 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackDialog.java @@ -0,0 +1,338 @@ +package io.sentry.android.core; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import io.sentry.IScopes; +import io.sentry.Sentry; +import io.sentry.SentryFeedbackOptions; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.protocol.Feedback; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryUserFeedbackDialog extends AlertDialog { + + private boolean isCancelable = false; + private @Nullable SentryId currentReplayId; + private @Nullable OnDismissListener delegate; + + private final @Nullable OptionsConfiguration configuration; + + SentryUserFeedbackDialog( + final @NotNull Context context, + final int themeResId, + final @Nullable OptionsConfiguration configuration) { + super(context, themeResId); + this.configuration = configuration; + } + + @Override + public void setCancelable(boolean cancelable) { + super.setCancelable(cancelable); + isCancelable = cancelable; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(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); + } + 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); + final @NotNull EditText edtName = findViewById(R.id.sentry_dialog_user_feedback_edt_name); + final @NotNull TextView lblEmail = findViewById(R.id.sentry_dialog_user_feedback_txt_email); + final @NotNull EditText edtEmail = findViewById(R.id.sentry_dialog_user_feedback_edt_email); + final @NotNull TextView lblMessage = + findViewById(R.id.sentry_dialog_user_feedback_txt_description); + final @NotNull EditText edtMessage = + findViewById(R.id.sentry_dialog_user_feedback_edt_description); + final @NotNull Button btnSend = findViewById(R.id.sentry_dialog_user_feedback_btn_send); + final @NotNull Button btnCancel = findViewById(R.id.sentry_dialog_user_feedback_btn_cancel); + + if (feedbackOptions.isShowBranding()) { + imgLogo.setVisibility(View.VISIBLE); + } else { + imgLogo.setVisibility(View.GONE); + } + + // If name is required, ignore showName flag + if (!feedbackOptions.isShowName() && !feedbackOptions.isNameRequired()) { + lblName.setVisibility(View.GONE); + edtName.setVisibility(View.GONE); + } else { + lblName.setVisibility(View.VISIBLE); + edtName.setVisibility(View.VISIBLE); + lblName.setText(feedbackOptions.getNameLabel()); + edtName.setHint(feedbackOptions.getNamePlaceholder()); + if (feedbackOptions.isNameRequired()) { + lblName.append(feedbackOptions.getIsRequiredLabel()); + } + } + + // If email is required, ignore showEmail flag + if (!feedbackOptions.isShowEmail() && !feedbackOptions.isEmailRequired()) { + lblEmail.setVisibility(View.GONE); + edtEmail.setVisibility(View.GONE); + } else { + lblEmail.setVisibility(View.VISIBLE); + edtEmail.setVisibility(View.VISIBLE); + lblEmail.setText(feedbackOptions.getEmailLabel()); + edtEmail.setHint(feedbackOptions.getEmailPlaceholder()); + if (feedbackOptions.isEmailRequired()) { + lblEmail.append(feedbackOptions.getIsRequiredLabel()); + } + } + + // If Sentry user is set, and useSentryUser is true, populate the name and email + if (feedbackOptions.isUseSentryUser()) { + final @Nullable User user = Sentry.getCurrentScopes().getScope().getUser(); + if (user != null) { + edtName.setText(user.getName()); + edtEmail.setText(user.getEmail()); + } + } + + lblMessage.setText(feedbackOptions.getMessageLabel()); + lblMessage.append(feedbackOptions.getIsRequiredLabel()); + edtMessage.setHint(feedbackOptions.getMessagePlaceholder()); + lblTitle.setText(feedbackOptions.getFormTitle()); + + btnSend.setText(feedbackOptions.getSubmitButtonLabel()); + btnSend.setOnClickListener( + v -> { + // Gather fields and trim them + final @NotNull String name = edtName.getText().toString().trim(); + final @NotNull String email = edtEmail.getText().toString().trim(); + final @NotNull String message = edtMessage.getText().toString().trim(); + + // If a required field is missing, shows the error label + if (name.isEmpty() && feedbackOptions.isNameRequired()) { + edtName.setError(lblName.getText()); + return; + } + + if (email.isEmpty() && feedbackOptions.isEmailRequired()) { + edtEmail.setError(lblEmail.getText()); + return; + } + + if (message.isEmpty()) { + edtMessage.setError(lblMessage.getText()); + return; + } + + // Create the feedback object + final @NotNull Feedback feedback = new Feedback(message); + feedback.setName(name); + feedback.setContactEmail(email); + if (currentReplayId != null) { + feedback.setReplayId(currentReplayId); + } + + // Capture the feedback. If the ID is empty, it means that the feedback was not sent + final @NotNull SentryId id = Sentry.captureFeedback(feedback); + if (!id.equals(SentryId.EMPTY_ID)) { + Toast.makeText( + getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT) + .show(); + final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitSuccess = + feedbackOptions.getOnSubmitSuccess(); + if (onSubmitSuccess != null) { + onSubmitSuccess.call(feedback); + } + } else { + final @Nullable SentryFeedbackOptions.SentryFeedbackCallback onSubmitError = + feedbackOptions.getOnSubmitError(); + if (onSubmitError != null) { + onSubmitError.call(feedback); + } + } + cancel(); + }); + + btnCancel.setText(feedbackOptions.getCancelButtonLabel()); + btnCancel.setOnClickListener(v -> cancel()); + setOnDismissListener(delegate); + } + + @Override + public void setOnDismissListener(final @Nullable OnDismissListener listener) { + delegate = listener; + // If the user set a custom onDismissListener, we ensure it doesn't override the onFormClose + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + final @Nullable Runnable onFormClose = options.getFeedbackOptions().getOnFormClose(); + if (onFormClose != null) { + super.setOnDismissListener( + dialog -> { + onFormClose.run(); + currentReplayId = null; + if (delegate != null) { + delegate.onDismiss(dialog); + } + }); + } else { + super.setOnDismissListener(delegate); + } + } + + @Override + protected void onStart() { + super.onStart(); + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); + final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); + if (onFormOpen != null) { + onFormOpen.run(); + } + options.getReplayController().captureReplay(false); + currentReplayId = options.getReplayController().getReplayId(); + } + + @Override + public void show() { + // If Sentry is disabled, don't show the dialog, but log a warning + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + final @NotNull SentryOptions options = scopes.getOptions(); + if (!scopes.isEnabled() || !options.isEnabled()) { + options + .getLogger() + .log(SentryLevel.WARNING, "Sentry is disabled. Feedback dialog won't be shown."); + return; + } + // Otherwise, show the dialog + super.show(); + } + + public static class Builder { + + @Nullable OptionsConfiguration configuration; + final @NotNull Context context; + final int themeResId; + + /** + * Creates a builder for a {@link SentryUserFeedbackDialog} that uses the default alert dialog + * theme. + * + *

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 + */ + public Builder(final @NotNull Context context) { + this(context, 0); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme + * resource. + * + *

The specified theme resource ({@code themeResId}) is applied on top of the parent {@code + * context}'s theme. It may be specified as a style resource containing a fully-populated theme, + * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the + * parent {@code context}'s theme including primary and accent colors. + * + *

To preserve attributes such as primary and accent colors, the {@code themeResId} may + * instead be specified as an overlay theme such as {@link + * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes + * necessary to style the alert window as a dialog. + * + *

Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent + * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * + * @param context the parent context + * @param themeResId the resource ID of the theme against which to inflate this dialog, or + * {@code 0} to use the parent {@code context}'s default alert dialog theme + */ + public Builder(Context context, int themeResId) { + this(context, themeResId, 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. + * + *

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 configuration the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder( + final @NotNull Context context, final @Nullable OptionsConfiguration configuration) { + this(context, 0, configuration); + } + + /** + * Creates a builder for a {@link SentryUserFeedbackDialog} that uses an explicit theme + * resource. The {@code configuration} can be used to configure the feedback options for this + * specific dialog. + * + *

The specified theme resource ({@code themeResId}) is applied on top of the parent {@code + * context}'s theme. It may be specified as a style resource containing a fully-populated theme, + * such as {@link android.R.style#Theme_Material_Dialog}, to replace all attributes in the + * parent {@code context}'s theme including primary and accent colors. + * + *

To preserve attributes such as primary and accent colors, the {@code themeResId} may + * instead be specified as an overlay theme such as {@link + * android.R.style#ThemeOverlay_Material_Dialog}. This will override only the window attributes + * necessary to style the alert window as a dialog. + * + *

Alternatively, the {@code themeResId} may be specified as {@code 0} to use the parent + * {@code context}'s resolved value for {@link android.R.attr#alertDialogTheme}. + * + * @param context the parent context + * @param themeResId the resource ID of the theme against which to inflate this dialog, or + * {@code 0} to use the parent {@code context}'s default alert dialog theme + * @param configuration the configuration for the feedback options, can be {@code null} to use + * the global feedback options. + */ + public Builder( + final @NotNull Context context, + final int themeResId, + final @Nullable OptionsConfiguration configuration) { + this.context = context; + this.themeResId = themeResId; + this.configuration = configuration; + } + + /** + * 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); + } + } + + /** Configuration callback for feedback options. */ + public interface OptionsConfiguration { + + /** + * configure the feedback options + * + * @param context the context of the feedback dialog + * @param options the feedback options + */ + void configure(final @NotNull Context context, final @NotNull SentryFeedbackOptions options); + } +} diff --git a/sentry-android-core/src/main/res/drawable-hdpi/sentry_logo_dark.webp b/sentry-android-core/src/main/res/drawable-hdpi/sentry_logo_dark.webp new file mode 100644 index 0000000000..107533bbf2 Binary files /dev/null and b/sentry-android-core/src/main/res/drawable-hdpi/sentry_logo_dark.webp differ diff --git a/sentry-android-core/src/main/res/drawable-mdpi/sentry_logo_dark.webp b/sentry-android-core/src/main/res/drawable-mdpi/sentry_logo_dark.webp new file mode 100644 index 0000000000..6237c1c7e8 Binary files /dev/null and b/sentry-android-core/src/main/res/drawable-mdpi/sentry_logo_dark.webp differ diff --git a/sentry-android-core/src/main/res/drawable-xhdpi/sentry_logo_dark.webp b/sentry-android-core/src/main/res/drawable-xhdpi/sentry_logo_dark.webp new file mode 100644 index 0000000000..d072a19a43 Binary files /dev/null and b/sentry-android-core/src/main/res/drawable-xhdpi/sentry_logo_dark.webp differ diff --git a/sentry-android-core/src/main/res/drawable-xxhdpi/sentry_logo_dark.webp b/sentry-android-core/src/main/res/drawable-xxhdpi/sentry_logo_dark.webp new file mode 100644 index 0000000000..7dceaec7fa Binary files /dev/null and b/sentry-android-core/src/main/res/drawable-xxhdpi/sentry_logo_dark.webp differ diff --git a/sentry-android-core/src/main/res/drawable-xxxhdpi/sentry_logo_dark.webp b/sentry-android-core/src/main/res/drawable-xxxhdpi/sentry_logo_dark.webp new file mode 100644 index 0000000000..0e61b7ebce Binary files /dev/null and b/sentry-android-core/src/main/res/drawable-xxxhdpi/sentry_logo_dark.webp differ diff --git a/sentry-android-core/src/main/res/drawable/sentry_edit_text_border.xml b/sentry-android-core/src/main/res/drawable/sentry_edit_text_border.xml new file mode 100644 index 0000000000..5615e31857 --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_edit_text_border.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml new file mode 100644 index 0000000000..e6f77b90a7 --- /dev/null +++ b/sentry-android-core/src/main/res/layout/sentry_dialog_user_feedback.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + +