Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
82e8007
Merge branch 'main' into feat/feedback-screenshot-attachment
markushi Aug 18, 2026
58f0ede
docs(agents): Remove duplicated build commands and rules (#5971)
runningcode Aug 21, 2026
2df6b56
perf(android): Use generated manifest metadata (#5976)
markushi Aug 24, 2026
e6eff53
Address PR feedback
markushi Aug 24, 2026
dbe756c
Merge branch 'main' into feat/feedback-screenshot-attachment
markushi Aug 25, 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
- Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888))
- Prevent a class of Session Replay deadlocks by confining lifecycle state changes to Android's main thread ([#5965](https://github.com/getsentry/sentry-java/pull/5965))

### 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

### Performance

- Defer starting Session Replay off the SDK initialization critical path ([#5965](https://github.com/getsentry/sentry-java/pull/5965))
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 @@ -191,6 +191,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 @@ -732,6 +735,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.
Comment thread
markushi marked this conversation as resolved.
}

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();
Comment thread
markushi marked this conversation as resolved.
} 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 thread
markushi marked this conversation as resolved.
Comment thread
markushi marked this conversation as resolved.
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);
Comment thread
markushi marked this conversation as resolved.
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
Comment thread
markushi marked this conversation as resolved.
// 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 {
final @Nullable InputStream inputStream = resolver.openInputStream(uri);
if (inputStream == null) {
throw new IOException("Unable to open image attachment: " + uri);
}
// inputStreamToByteArray closes the stream
return FileUtils.inputStreamToByteArray(inputStream, maxSize);
}

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