Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b1ceb7f
feat(feedback): Add screenshot attachment button to user feedback widget
markushi Jul 23, 2026
ff9326d
changelog
markushi Jul 23, 2026
bcfe6d6
changelog
markushi Jul 23, 2026
ca30932
build(android): Clean up androidx.activity dependency declarations
markushi Jul 23, 2026
f9edb84
fix(feedback): Address review feedback for screenshot attachment
markushi Jul 23, 2026
06702a3
Merge branch 'main' into feat/feedback-screenshot-attachment
markushi Jul 23, 2026
a4e341a
fix(feedback): Bound image attachment reads by maxAttachmentSize
markushi Jul 23, 2026
c8af66a
Merge branch 'feat/feedback-screenshot-attachment' of github.com:gets…
markushi Jul 23, 2026
dabb043
Address PR feedback
markushi Jul 24, 2026
c8c08c1
Merge branch 'main' into feat/feedback-screenshot-attachment
markushi Jul 24, 2026
0a3d51d
Merge branch 'main' into feat/feedback-screenshot-attachment
markushi Aug 12, 2026
eb9d1ed
Merge remote-tracking branch 'origin/main' into feat/feedback-screens…
markushi Aug 12, 2026
9411cc4
ref(feedback): Don't swallow fatal throwables in the screenshot attac…
markushi Aug 12, 2026
e54c598
fix(feedback): Make the user feedback form scrollable and compact its…
markushi Aug 13, 2026
416d6ca
test(feedback): Drop an inaccurate comment about duplicate picker reg…
markushi Aug 13, 2026
6794390
Merge remote-tracking branch 'origin/main' into feat/feedback-screens…
markushi Aug 13, 2026
5edad4a
ref(feedback): Narrow the screenshot attachment catches to the expect…
markushi Aug 13, 2026
1bdd114
ref(feedback): Guard the androidx.activity calls against LinkageErrors
markushi Aug 13, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Features

- Add screenshot attachment button to the Android user feedback widget ([#5828](https://github.com/getsentry/sentry-java/pull/5828))
- Users can now attach a screenshot when submitting feedback. Enabled by default; can be disabled via `SentryFeedbackOptions.setEnableAttachScreenshot(false)` or the `io.sentry.feedback.enable-attach-screenshot` manifest flag.
- Requires the `androidx.activity` `>=1.8.2` dependency

## 8.53.0

### Features
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ apollo3-kotlin = { module = "com.apollographql.apollo3:apollo-runtime", version
apollo4-kotlin = { module = "com.apollographql.apollo:apollo-runtime", version = "4.1.1" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.3.0" }
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.9.1" }
androidx-activity = { module = "androidx.activity:activity", version = "1.8.2" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.8.2" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidxCompose" }
androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidxCompose" }
Expand Down
3 changes: 3 additions & 0 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ dependencies {
implementation(libs.androidx.lifecycle.common.java8)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.core)
// photo picker for user feedback screenshot attachments
compileOnly(libs.androidx.activity)

implementation(libs.epitaph)

errorprone(libs.errorprone.core)
Expand Down
5 changes: 5 additions & 0 deletions sentry-android-core/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
-dontwarn io.sentry.compose.gestures.ComposeGestureTargetLocator
-dontwarn io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter

# androidx.activity is a compileOnly dependency, used by the user feedback screenshot picker
# its presence is checked at runtime before use
-dontwarn androidx.activity.ComponentActivity
-dontwarn androidx.activity.result.**

# To ensure that stack traces is unambiguous
# https://developer.android.com/studio/build/shrink-code#decode-stack-trace
-keepattributes LineNumberTable,SourceFile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ final class ManifestMetadataReader {

static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture";

static final String FEEDBACK_ENABLE_ATTACH_SCREENSHOT =
"io.sentry.feedback.enable-attach-screenshot";

static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable";

static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url";
Expand Down Expand Up @@ -728,6 +731,12 @@ static void applyMetadata(
feedbackOptions.setUseShakeGesture(
readBool(
metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture()));
feedbackOptions.setEnableAttachScreenshot(
readBool(
metadata,
logger,
FEEDBACK_ENABLE_ATTACH_SCREENSHOT,
feedbackOptions.isEnableAttachScreenshot()));

options.setStrictTraceContinuation(
readBool(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.sentry.android.core;

import android.app.Activity;
import android.net.Uri;
import androidx.activity.ComponentActivity;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.PickVisualMediaRequest;
import androidx.activity.result.contract.ActivityResultContracts;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Launches the androidx photo picker to attach an screenshot to user feedback. All
* androidx.activity references are isolated in this class, so it must only be loaded once {@link
* SentryUserFeedbackForm#isScreenshotPickerAvailable} has returned true. That check deliberately
* lives outside of this class, so that it can run without linking any androidx.activity type.
*/
final class SentryFeedbackScreenshotPicker {

interface OnScreenshotPicked {
void onScreenshotPicked(@NotNull Uri uri);
}

private final @NotNull ActivityResultLauncher<PickVisualMediaRequest> launcher;

private SentryFeedbackScreenshotPicker(
final @NotNull ActivityResultLauncher<PickVisualMediaRequest> launcher) {
this.launcher = launcher;
}

static @Nullable SentryFeedbackScreenshotPicker register(
final @NotNull Activity activity,
final @NotNull SentryFeedbackScreenshotPicker.OnScreenshotPicked callback) {
if (!(activity instanceof ComponentActivity)) {
return null;
}
final @NotNull ActivityResultLauncher<PickVisualMediaRequest> launcher =
((ComponentActivity) activity)
.getActivityResultRegistry()
.register(
"sentry_user_feedback_screenshot_picker",
new ActivityResultContracts.PickVisualMedia(),
(@Nullable Uri uri) -> {
if (uri != null) {
callback.onScreenshotPicked(uri);
}
});
return new SentryFeedbackScreenshotPicker(launcher);
Comment thread
sentry[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared picker key breaks stacked forms

Medium Severity

The screenshot picker always registers on the host ActivityResultRegistry under the fixed key sentry_user_feedback_screenshot_picker. A second SentryUserFeedbackForm on the same ComponentActivity overwrites that registration. Dismissing the first form then calls unregister() on the shared key and tears down the still-visible form's launcher, so its add-screenshot action fails.

Additional Locations (1)
Fix in CursorΒ Fix in Web

Reviewed by Cursor Bugbot for commit 5edad4a. Configure here.

}

void launch() {
launcher.launch(
new PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE)
.build());
}

void unregister() {
launcher.unregister();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Application;
import android.content.ContentResolver;
import android.content.Context;
import android.content.ContextWrapper;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.webkit.MimeTypeMap;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import io.sentry.Attachment;
import io.sentry.Hint;
import io.sentry.IScopes;
import io.sentry.Sentry;
import io.sentry.SentryFeedbackOptions;
Expand All @@ -23,6 +30,11 @@
import io.sentry.protocol.Feedback;
import io.sentry.protocol.SentryId;
import io.sentry.protocol.User;
import io.sentry.util.ExceptionUtils;
import io.sentry.util.FileUtils;
import io.sentry.util.LoadClass;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -39,6 +51,9 @@ public class SentryUserFeedbackForm extends AlertDialog {
private @Nullable SentryShakeDetector shakeDetector;
private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks;

private @Nullable SentryFeedbackScreenshotPicker screenshotPicker;
private @Nullable Uri selectedImageUri;

SentryUserFeedbackForm(
final @NotNull Context context,
final int themeResId,
Expand Down Expand Up @@ -203,6 +218,38 @@ protected void onCreate(Bundle savedInstanceState) {
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);
final @NotNull Button btnAddScreenshot =
findViewById(R.id.sentry_dialog_user_feedback_btn_add_screenshot);

// The button is made visible in onStart, once the screenshot picker is registered successfully
btnAddScreenshot.setVisibility(View.GONE);
btnAddScreenshot.setOnClickListener(
v -> {
if (selectedImageUri == null) {
if (screenshotPicker != null) {
try {
screenshotPicker.launch();
} catch (LinkageError e) {
// androidx.activity is compileOnly, so the version in the app's apk may not have
// the photo picker APIs this was compiled against
Sentry.getCurrentScopes()
.getOptions()
.getLogger()
.log(SentryLevel.ERROR, "Failed to launch the screenshot picker.", e);
} catch (Throwable t) {
ExceptionUtils.rethrowIfFatal(t);
// e.g. no photo picker on the device, or the launcher is no longer registered
Sentry.getCurrentScopes()
.getOptions()
.getLogger()
.log(SentryLevel.ERROR, "Failed to launch the screenshot picker.", t);
}
}
} else {
selectedImageUri = null;
btnAddScreenshot.setText(feedbackOptions.getAddScreenshotButtonLabel());
}
});

if (feedbackOptions.isShowBranding()) {
imgLogo.setVisibility(View.VISIBLE);
Expand Down Expand Up @@ -288,7 +335,9 @@ protected void onCreate(Bundle savedInstanceState) {
}

// Capture the feedback. If the ID is empty, it means that the feedback was not sent
final @NotNull SentryId id = Sentry.feedback().capture(feedback);
final @NotNull Hint hint = new Hint();
maybeAddImageAttachment(hint);
final @NotNull SentryId id = Sentry.feedback().capture(feedback, hint);
if (!id.equals(SentryId.EMPTY_ID)) {
Toast.makeText(
getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT)
Expand Down Expand Up @@ -365,6 +414,7 @@ protected void onStart() {
edtMessage.setError(null);

final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions();
maybeRegisterScreenshotPicker(options);
Comment on lines 415 to +417

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The screenshot button remains visible after being disabled. If isEnableAttachScreenshot is set to false between form displays, the button's visibility is not updated, leaving it incorrectly visible.
Severity: LOW

Suggested Fix

In the maybeRegisterScreenshotPicker() method, ensure the button's visibility is always set regardless of the isEnableAttachScreenshot() value. When disabled, explicitly set btnAddScreenshot.setVisibility(View.GONE) before the early return to correctly reflect the current configuration.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java#L415-L417

Potential issue: The visibility of the 'attach screenshot' button is not correctly
updated if the `SentryFeedbackOptions.isEnableAttachScreenshot()` setting is changed
while the feedback form is not visible. If the form is shown with screenshots enabled,
then hidden, and then the setting is disabled, the button will incorrectly remain
visible when the form is shown again. This happens because the
`maybeRegisterScreenshotPicker()` method has an early return when the feature is
disabled, which skips the logic that would hide the button. The button's visibility
state from the previous showing persists.

Comment on lines 415 to +417

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The screenshot button remains visible after being disabled. If isEnableAttachScreenshot is set to false between form displays, the button's visibility is not updated, leaving it incorrectly visible.
Severity: LOW

Suggested Fix

In the maybeRegisterScreenshotPicker() method, ensure the button's visibility is always set regardless of the isEnableAttachScreenshot() value. When disabled, explicitly set btnAddScreenshot.setVisibility(View.GONE) before the early return to correctly reflect the current configuration.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java#L415-L417

Potential issue: The visibility of the 'attach screenshot' button is not correctly
updated if the `SentryFeedbackOptions.isEnableAttachScreenshot()` setting is changed
while the feedback form is not visible. If the form is shown with screenshots enabled,
then hidden, and then the setting is disabled, the button will incorrectly remain
visible when the form is shown again. This happens because the
`maybeRegisterScreenshotPicker()` method has an early return when the feature is
disabled, which skips the logic that would hide the button. The button's visibility
state from the previous showing persists.

final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions();
// Pause shake-to-report on this dialog's activity while it is visible, so a shake can't stack
// a second dialog on top of it
Expand All @@ -388,12 +438,160 @@ protected void onStart() {
@Override
protected void onStop() {
super.onStop();
if (screenshotPicker != null) {
screenshotPicker.unregister();
screenshotPicker = null;
}
final @Nullable FeedbackShakeIntegration integration = getFeedbackShakeIntegration();
if (integration != null) {
integration.onDialogGone(this);
}
}

private void maybeRegisterScreenshotPicker(final @NotNull SentryOptions options) {
// Clear any previously selected image so subsequent show() calls start with a fresh form
final @NotNull Button btnAddScreenshot =
findViewById(R.id.sentry_dialog_user_feedback_btn_add_screenshot);
selectedImageUri = null;
btnAddScreenshot.setText(resolvedFeedbackOptions.getAddScreenshotButtonLabel());

if (!resolvedFeedbackOptions.isEnableAttachScreenshot()) {
return;
}
final @Nullable Activity activity = getActivity(getContext());
if (activity != null && isScreenshotPickerAvailable(options)) {
try {
screenshotPicker =
SentryFeedbackScreenshotPicker.register(
activity, uri -> onScreenshotPicked(options, btnAddScreenshot, uri));
} catch (LinkageError e) {
// This is where androidx.activity is linked for the first time. It is a compileOnly
// dependency, so the version in the app's apk may be older than the one we compiled
// against, or missing the photo picker APIs entirely.
options
.getLogger()
.log(SentryLevel.INFO, "androidx.activity is too old for the screenshot picker.", e);
} catch (Throwable t) {
ExceptionUtils.rethrowIfFatal(t);
options.getLogger().log(SentryLevel.ERROR, "Failed to register the screenshot picker.", t);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (screenshotPicker != null) {
btnAddScreenshot.setVisibility(View.VISIBLE);
} else {
btnAddScreenshot.setVisibility(View.GONE);
options
.getLogger()
.log(
SentryLevel.WARNING,
"Feedback screenshot button won't be shown. It requires the androidx.activity "
+ "dependency and the feedback form being shown from a ComponentActivity.");
Comment thread
sentry[bot] marked this conversation as resolved.
}
}

/**
* Must be called before {@link SentryFeedbackScreenshotPicker} is touched for the first time, as
* that class links against androidx.activity types.
*/
private boolean isScreenshotPickerAvailable(final @NotNull SentryOptions options) {
final @NotNull LoadClass loadClass = resolvedFeedbackOptions.getLoadClass();
return loadClass.isClassAvailable("androidx.activity.ComponentActivity", options)
&& loadClass.isClassAvailable(
"androidx.activity.result.contract.ActivityResultContracts$PickVisualMedia", options);
}

private void onScreenshotPicked(
final @NotNull SentryOptions options,
final @NotNull Button btnAddScreenshot,
final @NotNull Uri uri) {
final long size = getUriSize(options, getContext().getContentResolver(), uri);
if (size > options.getMaxAttachmentSize()) {
Comment thread
sentry[bot] marked this conversation as resolved.
options
.getLogger()
.log(
SentryLevel.WARNING,
"Selected screenshot is larger than the maxAttachmentSize of %d bytes, dropping it.",
options.getMaxAttachmentSize());
Toast.makeText(
getContext(),
resolvedFeedbackOptions.getScreenshotTooLargeMessageText(),
Toast.LENGTH_SHORT)
.show();
return;
}
selectedImageUri = uri;
btnAddScreenshot.setText(resolvedFeedbackOptions.getRemoveScreenshotButtonLabel());
}

private void maybeAddImageAttachment(final @NotNull Hint hint) {
final @Nullable Uri imageUri = selectedImageUri;
if (imageUri == null) {
return;
}
try {
final @NotNull ContentResolver resolver = getContext().getContentResolver();
final @Nullable String resolvedMime = resolver.getType(imageUri);
final @NotNull String mime = resolvedMime != null ? resolvedMime : "image/png";
final @Nullable String resolvedExt =
MimeTypeMap.getSingleton().getExtensionFromMimeType(mime);
final @NotNull String ext = resolvedExt != null ? resolvedExt : "png";
hint.addAttachment(
new Attachment(
() ->
readUriBytes(
resolver,
imageUri,
Sentry.getCurrentScopes().getOptions().getMaxAttachmentSize()),
"screenshot." + ext,
mime,
"event.attachment",
false));
Comment thread
markushi marked this conversation as resolved.
} catch (Throwable t) {
ExceptionUtils.rethrowIfFatal(t);
// The ContentResolver call crosses into the provider's process, which can fail in any number
// of ways, e.g. a SecurityException once the read permission for the picked Uri was revoked
Sentry.getCurrentScopes()
.getOptions()
.getLogger()
.log(SentryLevel.ERROR, "Failed to attach image to feedback.", t);
}
}

private static long getUriSize(
final @NotNull SentryOptions options,
final @NotNull ContentResolver resolver,
final @NotNull Uri uri) {
try (final @Nullable Cursor cursor = resolver.query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
final int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
return cursor.getLong(sizeIndex);
}
}
} catch (Throwable t) {
ExceptionUtils.rethrowIfFatal(t);
options
.getLogger()
.log(
SentryLevel.WARNING,
"Unable to determine the size of the selected screenshot, the attachment size limit "
+ "is applied when the feedback is captured.",
t);
}
return -1;
}

private static byte[] readUriBytes(
final @NotNull ContentResolver resolver, final @NotNull Uri uri, final long maxSize)
throws IOException {
try (final @Nullable InputStream inputStream = resolver.openInputStream(uri)) {
if (inputStream == null) {
throw new IOException("Unable to open image attachment: " + uri);
}
return FileUtils.inputStreamToByteArray(inputStream, maxSize);
}
}

@Override
public void onDetachedFromWindow() {
super.onDetachedFromWindow();
Expand Down
Loading
Loading