Skip to content

New User Feedback Widget #4450

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 49 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
62e71ff
added Feedback class and extended Contexts with it
stefanosiano Mar 14, 2025
c9b4850
Added Sentry.captureFeedback API
stefanosiano Mar 25, 2025
813ff1d
updated changelog
stefanosiano Mar 25, 2025
dd2d6a8
added tests
stefanosiano Mar 27, 2025
6428dfa
added scope replay id and screen as url
stefanosiano Mar 31, 2025
144bcca
added feedback as DataCategory for rate limit and client report
stefanosiano Apr 16, 2025
509f992
Merge branch 'refs/heads/main' into feat/new-user-feedback-logic
stefanosiano Apr 16, 2025
3c0a6fb
merged main
stefanosiano Apr 16, 2025
9e1066e
added tests
stefanosiano Apr 17, 2025
eadb567
Merge branch 'refs/heads/main' into feat/new-user-feedback-logic
stefanosiano Apr 17, 2025
8feeadc
fixed tests
stefanosiano Apr 17, 2025
986772d
started adding resources for UF widget
stefanosiano Apr 29, 2025
e19d122
started SentryFeedbackOptions
stefanosiano Apr 30, 2025
79052ad
Merge branch 'feat/new-user-feedback-logic' into feat/user-feedback-w…
stefanosiano May 7, 2025
5a83bb8
added all form options
stefanosiano May 9, 2025
ee44b18
Merge branch 'main' into feat/new-user-feedback-logic
stefanosiano May 9, 2025
212ffbf
merged main
stefanosiano May 9, 2025
2bf99b5
Merge branch 'feat/new-user-feedback-logic' into feat/user-feedback-w…
stefanosiano May 9, 2025
6eb876c
user feedback dialog now uses dialogTheme
stefanosiano May 14, 2025
a33ae00
Merge branch 'main' into feat/user-feedback-widget
stefanosiano May 14, 2025
d47f374
merged main
stefanosiano May 14, 2025
1a758a2
removed java options for send button colors. Replaced with theme sett…
stefanosiano May 14, 2025
9315f7b
added tests and UI tests
stefanosiano May 16, 2025
62cea5e
added comments
stefanosiano May 16, 2025
794bd66
added comments
stefanosiano May 16, 2025
f16ac66
added replay capturing on feedback dialog open
stefanosiano May 16, 2025
5388b42
added cancel button ui test
stefanosiano May 19, 2025
8aacd36
added cancel button ui test
stefanosiano May 19, 2025
8750c8d
Merge branch 'refs/heads/main' into feat/user-feedback-widget
stefanosiano May 19, 2025
6d14499
added Feedback.toString() and default values in javadoc of SentryFeed…
stefanosiano May 19, 2025
fb88c1f
merged main
stefanosiano May 19, 2025
b5eb64d
skipping replay in test when running on gh
stefanosiano May 20, 2025
d0ba126
skipping replay in test when running on gh
stefanosiano May 20, 2025
202759f
Merge branch 'main' into feat/user-feedback-widget
markushi May 22, 2025
6253aca
added SentryUserFeedbackWidget, with styles and icon
stefanosiano May 26, 2025
c6d1919
updated changelog
stefanosiano May 26, 2025
7bc01eb
Merge branch 'main' into feat/user-feedback-widget
stefanosiano May 29, 2025
ce10336
Merge branch 'feat/user-feedback-widget' into feat/user-feedback-button
stefanosiano May 29, 2025
933371d
Update CHANGELOG.md
stefanosiano May 29, 2025
eebaaf3
removed rtl properties and supportsRtl flag
stefanosiano Jun 6, 2025
3fef7c4
updated changelog
stefanosiano Jun 6, 2025
536d522
Merge branch 'main' into feat/user-feedback-widget
stefanosiano Jun 6, 2025
6d9cee3
Merge branch 'feat/user-feedback-widget' into feat/user-feedback-button
stefanosiano Jun 6, 2025
1bb3095
merged main
stefanosiano Jun 6, 2025
67cbc0d
Merge branch 'main' into feat/user-feedback-button
stefanosiano Jun 13, 2025
4e5ad3a
merged main
stefanosiano Jun 13, 2025
5cdbaca
fixed ui tests
stefanosiano Jun 16, 2025
8a1ef90
Renamed SentryUserFeedbackWidget to SentryUserFeedbackButton
stefanosiano Jun 17, 2025
b504f79
Merge branch 'main' into feat/user-feedback-button
stefanosiano Jun 17, 2025
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 @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Landroid/content/Context;)V
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;I)V
public fun <init> (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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">

<item>
<shape android:shape="rectangle">
<solid android:color="?android:attr/colorBackground" />
<corners android:radius="50dp" />
</shape>
</item>
</ripple>
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: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"/>

</vector>
12 changes: 12 additions & 0 deletions sentry-android-core/src/main/res/values/attrs.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SentryUserFeedbackButton" >
<attr name="android:drawableStart" format="reference" />
<attr name="android:drawablePadding" format="dimension" />
<attr name="android:padding" format="dimension" />
<attr name="android:textAllCaps" format="boolean" />
<attr name="android:background" format="reference|color" />
<attr name="android:textColor" format="reference|color" />
<attr name="android:text" format="string" />
</declare-styleable>
</resources>
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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<Int>("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<Int>("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)))
Expand Down Expand Up @@ -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<EmptyActivity>()
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<View> {
return object : BoundedMatcher<View, EditText>(EditText::class.java) {
override fun describeTo(description: Description) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
android:layout_height="wrap_content"
android:text="@string/send_message" />

<io.sentry.android.core.SentryUserFeedbackButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

<Button
android:id="@+id/send_user_feedback"
android:layout_width="wrap_content"
Expand Down
Loading
Loading