diff --git a/CHANGELOG.md b/CHANGELOG.md index d70690f560..9e14772a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Features +- Add New User Feedback Widget ([#4450](https://github.com/getsentry/sentry-java/pull/4450)) + - This widget is a custom button that can be used to show the user feedback form - 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). diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 352f8111c6..ea67ed5bcb 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -385,6 +385,14 @@ public final class io/sentry/android/core/SentryPerformanceProvider { public fun shutdown ()V } +public class io/sentry/android/core/SentryUserFeedbackButton : android/widget/Button { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;I)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;II)V + public fun setOnClickListener (Landroid/view/View$OnClickListener;)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 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java new file mode 100644 index 0000000000..eedafd8f00 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackButton.java @@ -0,0 +1,122 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.Button; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class SentryUserFeedbackButton extends Button { + + private @Nullable OnClickListener delegate; + + public SentryUserFeedbackButton(Context context) { + super(context); + init(context, null, 0, 0); + } + + public SentryUserFeedbackButton(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0, 0); + } + + public SentryUserFeedbackButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr, 0); + } + + public SentryUserFeedbackButton( + Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr, defStyleRes); + } + + @SuppressLint("SetTextI18n") + @SuppressWarnings("deprecation") + private void init( + final @NotNull Context context, + final @Nullable AttributeSet attrs, + final int defStyleAttr, + final int defStyleRes) { + try (final @NotNull TypedArray typedArray = + context.obtainStyledAttributes( + attrs, R.styleable.SentryUserFeedbackButton, defStyleAttr, defStyleRes)) { + final float dimensionScale = context.getResources().getDisplayMetrics().density; + final float drawablePadding = + typedArray.getDimension(R.styleable.SentryUserFeedbackButton_android_drawablePadding, -1); + final int drawableStart = + typedArray.getResourceId(R.styleable.SentryUserFeedbackButton_android_drawableStart, -1); + final boolean textAllCaps = + typedArray.getBoolean(R.styleable.SentryUserFeedbackButton_android_textAllCaps, false); + final int background = + typedArray.getResourceId(R.styleable.SentryUserFeedbackButton_android_background, -1); + final float padding = + typedArray.getDimension(R.styleable.SentryUserFeedbackButton_android_padding, -1); + final int textColor = + typedArray.getColor(R.styleable.SentryUserFeedbackButton_android_textColor, -1); + final @Nullable String text = + typedArray.getString(R.styleable.SentryUserFeedbackButton_android_text); + + // If the drawable padding is not set, set it to 4dp + if (drawablePadding == -1) { + setCompoundDrawablePadding((int) (4 * dimensionScale)); + } + + // If the drawable start is not set, set it to the default drawable + if (drawableStart == -1) { + setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.sentry_user_feedback_button_logo_24, 0, 0, 0); + } + + // Set the text all caps + setAllCaps(textAllCaps); + + // If the background is not set, set it to the default background + if (background == -1) { + setBackgroundResource(R.drawable.sentry_oval_button_ripple_background); + } + + // If the padding is not set, set it to 12dp + if (padding == -1) { + int defaultPadding = (int) (12 * dimensionScale); + setPadding(defaultPadding, defaultPadding, defaultPadding, defaultPadding); + } + + // If the text color is not set, set it to the default text color + if (textColor == -1) { + // We need the TypedValue to resolve the color from the theme + final @NotNull TypedValue typedValue = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.colorForeground, typedValue, true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setTextColor(context.getResources().getColor(typedValue.resourceId, context.getTheme())); + } else { + setTextColor(context.getResources().getColor(typedValue.resourceId)); + } + } + + // If the text is not set, set it to "Report a Bug" + if (text == null) { + setText("Report a Bug"); + } + } + + // Set the default ClickListener to open the SentryUserFeedbackDialog + setOnClickListener(delegate); + } + + @Override + public void setOnClickListener(final @Nullable OnClickListener listener) { + delegate = listener; + super.setOnClickListener( + v -> { + new SentryUserFeedbackDialog.Builder(getContext()).create().show(); + if (delegate != null) { + delegate.onClick(v); + } + }); + } +} diff --git a/sentry-android-core/src/main/res/drawable/sentry_oval_button_ripple_background.xml b/sentry-android-core/src/main/res/drawable/sentry_oval_button_ripple_background.xml new file mode 100644 index 0000000000..10e58ce27f --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_oval_button_ripple_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + 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 new file mode 100644 index 0000000000..52f0a8f044 --- /dev/null +++ b/sentry-android-core/src/main/res/drawable/sentry_user_feedback_button_logo_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sentry-android-core/src/main/res/values/attrs.xml b/sentry-android-core/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..c464cf29f0 --- /dev/null +++ b/sentry-android-core/src/main/res/values/attrs.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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 915085d6a0..5a94dfb5d4 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 @@ -1,7 +1,10 @@ package io.sentry.uitest.android +import android.graphics.Color +import android.util.TypedValue import android.view.View import android.widget.EditText +import android.widget.LinearLayout import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -23,9 +26,11 @@ import io.sentry.SentryFeedbackOptions.SentryFeedbackCallback import io.sentry.SentryOptions import io.sentry.android.core.AndroidLogger 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.User +import io.sentry.test.getProperty import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -34,6 +39,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -517,6 +523,95 @@ class UserFeedbackUiTest : BaseUiTest() { } } + @Test + fun userFeedbackWidgetDefaults() { + initSentry() + var widgetId = 0 + showWidgetAndCheck { widget -> + widgetId = widget.id + val densityScale = context.resources.displayMetrics.density + assertEquals((densityScale * 4).toInt(), widget.compoundDrawablePadding) + + assertNotNull(widget.compoundDrawables[0]) // Drawable left + assertNull(widget.compoundDrawables[1]) // Drawable top + assertNull(widget.compoundDrawables[2]) // Drawable right + assertNull(widget.compoundDrawables[3]) // Drawable bottom + + // Couldn't find a reliable way to check the drawable, so i'll skip it + + assertFalse(widget.isAllCaps) + + assertEquals(R.drawable.sentry_oval_button_ripple_background, widget.getProperty("mBackgroundResource")) + + assertEquals((densityScale * 12).toInt(), widget.paddingStart) + assertEquals((densityScale * 12).toInt(), widget.paddingEnd) + assertEquals((densityScale * 12).toInt(), widget.paddingTop) + assertEquals((densityScale * 12).toInt(), widget.paddingBottom) + + val typedValue = TypedValue() + widget.context.theme.resolveAttribute(android.R.attr.colorForeground, typedValue, true) + assertEquals(typedValue.data, widget.currentTextColor) + + assertEquals("Report a Bug", widget.text) + } + + onView(withId(widgetId)).perform(click()) + // Check that the user feedback dialog is shown + checkViewVisibility(R.id.sentry_dialog_user_feedback_layout) + } + + @Test + fun userFeedbackWidgetDefaultsOverridden() { + initSentry() + showWidgetAndCheck({ widget -> + widget.compoundDrawablePadding = 1 + widget.setCompoundDrawables(null, null, null, null) + widget.isAllCaps = true + widget.setBackgroundResource(R.drawable.sentry_edit_text_border) + widget.setTextColor(Color.RED) + widget.text = "My custom text" + widget.setPadding(0, 0, 0, 0) + }) { widget -> + val densityScale = context.resources.displayMetrics.density + assertEquals(1, widget.compoundDrawablePadding) + + assertNull(widget.compoundDrawables[0]) // Drawable left + assertNull(widget.compoundDrawables[1]) // Drawable top + assertNull(widget.compoundDrawables[2]) // Drawable right + assertNull(widget.compoundDrawables[3]) // Drawable bottom + + assertTrue(widget.isAllCaps) + + assertEquals(R.drawable.sentry_edit_text_border, widget.getProperty("mBackgroundResource")) + + assertEquals((densityScale * 0).toInt(), widget.paddingStart) + assertEquals((densityScale * 0).toInt(), widget.paddingEnd) + assertEquals((densityScale * 0).toInt(), widget.paddingTop) + assertEquals((densityScale * 0).toInt(), widget.paddingBottom) + + assertEquals(Color.RED, widget.currentTextColor) + + assertEquals("My custom text", widget.text) + } + } + + @Test + fun userFeedbackWidgetShowsDialogOnClickOverridden() { + initSentry() + var widgetId = 0 + var customListenerCalled = false + showWidgetAndCheck { widget -> + widgetId = widget.id + widget.setOnClickListener { customListenerCalled = true } + } + + onView(withId(widgetId)).perform(click()) + // Check that the user feedback dialog is shown + checkViewVisibility(R.id.sentry_dialog_user_feedback_layout) + // And the custom listener is called, too + assertTrue(customListenerCalled) + } + private fun checkViewVisibility(viewId: Int, isGone: Boolean = false) { onView(withId(viewId)) .check(matches(withEffectiveVisibility(if (isGone) Visibility.GONE else Visibility.VISIBLE))) @@ -558,6 +653,29 @@ class UserFeedbackUiTest : BaseUiTest() { checker(dialog) } + private fun showWidgetAndCheck(widgetConfig: ((widget: SentryUserFeedbackButton) -> Unit)? = null, checker: (widget: SentryUserFeedbackButton) -> Unit = {}) { + val buttonId = Int.MAX_VALUE - 1 + val feedbackScenario = launchActivity() + feedbackScenario.onActivity { + val view = LinearLayout(it).apply { + orientation = LinearLayout.VERTICAL + addView( + SentryUserFeedbackButton(it).apply { + id = buttonId + widgetConfig?.invoke(this) + } + ) + } + it.setContentView(view) + } + checkViewVisibility(buttonId) + onView(withId(buttonId)) + .check(matches(isDisplayed())) + .check { view, _ -> + checker(view as SentryUserFeedbackButton) + } + } + fun withError(expectedError: String): Matcher { return object : BoundedMatcher(EditText::class.java) { override fun describeTo(description: Description) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 2df689e96a..1b9acc3c26 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -11,8 +11,8 @@ import io.sentry.ISpan; import io.sentry.MeasurementUnit; import io.sentry.Sentry; -import io.sentry.android.core.SentryUserFeedbackDialog; import io.sentry.instrumentation.file.SentryFileOutputStream; +import io.sentry.protocol.Feedback; import io.sentry.protocol.User; import io.sentry.samples.android.compose.ComposeActivity; import io.sentry.samples.android.databinding.ActivityMainBinding; @@ -74,7 +74,11 @@ protected void onCreate(Bundle savedInstanceState) { binding.sendUserFeedback.setOnClickListener( view -> { - new SentryUserFeedbackDialog.Builder(this).create().show(); + Feedback feedback = + new Feedback("It broke on Android. I don't know why, but this happens."); + feedback.setContactEmail("john@me.com"); + feedback.setName("John Me"); + Sentry.captureFeedback(feedback); }); binding.addAttachment.setOnClickListener( diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index ba7ae0a653..d2eda41a38 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -22,6 +22,10 @@ android:layout_height="wrap_content" android:text="@string/send_message" /> + +