From b1ceb7f5f3ca203758ed4b9a012548f0fcf08dfb Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 23 Jul 2026 18:40:59 +0200 Subject: [PATCH 1/7] feat(feedback): Add screenshot attachment button to user feedback widget --- gradle/libs.versions.toml | 1 + .../api/sentry-android-core.api | 1 + sentry-android-core/build.gradle.kts | 4 + sentry-android-core/proguard-rules.pro | 5 + .../android/core/ManifestMetadataReader.java | 8 + .../core/SentryFeedbackPhotoPicker.java | 72 ++++++++ .../android/core/SentryUserFeedbackForm.java | 146 ++++++++++++++- .../layout/sentry_dialog_user_feedback.xml | 12 +- .../core/ManifestMetadataReaderTest.kt | 25 +++ .../core/SentryUserFeedbackFormTest.kt | 170 +++++++++++++++++- sentry/api/sentry.api | 6 + .../java/io/sentry/SentryFeedbackOptions.java | 83 +++++++++ .../io/sentry/SentryFeedbackOptionsTest.kt | 9 + 13 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackPhotoPicker.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89de8fce039..7554e6ef85a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adebedf2700..772d8325e88 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -556,6 +556,7 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackDialog$ public class io/sentry/android/core/SentryUserFeedbackForm : android/app/AlertDialog { protected fun onCreate (Landroid/os/Bundle;)V protected fun onStart ()V + protected fun onStop ()V public fun setCancelable (Z)V public fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)V public fun show ()V diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f92876530fd..8bcd335e752 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -95,6 +95,8 @@ dependencies { compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) compileOnly(projects.sentryAndroidDistribution) + // photo picker for user feedback screenshot attachments + compileOnly(libs.androidx.activity) // lifecycle processor, session tracking implementation(libs.androidx.lifecycle.common.java8) @@ -115,6 +117,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) @@ -125,6 +128,7 @@ dependencies { testImplementation(projects.sentryCompose) testImplementation(projects.sentryAndroidNdk) + testImplementation(libs.androidx.activity) testImplementation(libs.androidx.activity.compose) testImplementation(libs.androidx.compose.ui) testImplementation(libs.androidx.compose.foundation) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 4cd76f9a20d..de0b11debca 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -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 photo 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 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 7a9cd8a4d13..8786066489c 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 @@ -185,6 +185,8 @@ final class ManifestMetadataReader { static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture"; + static final String FEEDBACK_ENABLE_SCREENSHOT = "io.sentry.feedback.enable-screenshot"; + static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable"; static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url"; @@ -723,6 +725,12 @@ static void applyMetadata( feedbackOptions.setUseShakeGesture( readBool( metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture())); + feedbackOptions.setEnableScreenshot( + readBool( + metadata, + logger, + FEEDBACK_ENABLE_SCREENSHOT, + feedbackOptions.isEnableScreenshot())); options.setStrictTraceContinuation( readBool( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackPhotoPicker.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackPhotoPicker.java new file mode 100644 index 00000000000..975b0b0786c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackPhotoPicker.java @@ -0,0 +1,72 @@ +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 io.sentry.SentryOptions; +import io.sentry.util.LoadClass; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Launches the androidx photo picker to attach an image to user feedback. All androidx.activity + * references are isolated in this class, so it must only be loaded after {@link #isAvailable} has + * returned true. + */ +final class SentryFeedbackPhotoPicker { + + interface OnImagePicked { + void onImagePicked(@Nullable Uri uri); + } + + private final @NotNull ActivityResultLauncher launcher; + + private SentryFeedbackPhotoPicker( + final @NotNull ActivityResultLauncher launcher) { + this.launcher = launcher; + } + + /** + * Checks whether androidx.activity is on the classpath. Safe to call unconditionally, as it + * references no androidx types in its signature or body. + */ + static boolean isAvailable( + final @NotNull LoadClass loadClass, final @NotNull SentryOptions options) { + return loadClass.isClassAvailable("androidx.activity.ComponentActivity", options) + && loadClass.isClassAvailable( + "androidx.activity.result.contract.ActivityResultContracts$PickVisualMedia", options); + } + + /** + * Registers a photo picker on the activity's result registry, or returns null if the activity is + * not a {@link ComponentActivity}. Callers must {@link #unregister()} when done. + */ + static @Nullable SentryFeedbackPhotoPicker register( + final @NotNull Activity activity, final @NotNull OnImagePicked callback) { + if (!(activity instanceof ComponentActivity)) { + return null; + } + final @NotNull ActivityResultLauncher launcher = + ((ComponentActivity) activity) + .getActivityResultRegistry() + .register( + "sentry_user_feedback_photo_picker", + new ActivityResultContracts.PickVisualMedia(), + callback::onImagePicked); + return new SentryFeedbackPhotoPicker(launcher); + } + + void launch() { + launcher.launch( + new PickVisualMediaRequest.Builder() + .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE) + .build()); + } + + void unregister() { + launcher.unregister(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java index 43500d50ebc..8f1d79490d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryUserFeedbackForm.java @@ -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; @@ -23,6 +30,10 @@ import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.LoadClass; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -39,6 +50,15 @@ public class SentryUserFeedbackForm extends AlertDialog { private @Nullable SentryShakeDetector shakeDetector; private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks; + private @NotNull LoadClass loadClass = new LoadClass(); + private @Nullable SentryFeedbackPhotoPicker photoPicker; + private @Nullable Uri selectedImageUri; + + // for testing + void setLoadClass(final @NotNull LoadClass loadClass) { + this.loadClass = loadClass; + } + SentryUserFeedbackForm( final @NotNull Context context, final int themeResId, @@ -191,6 +211,22 @@ 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 photo picker is registered successfully + btnAddScreenshot.setVisibility(View.GONE); + btnAddScreenshot.setOnClickListener( + v -> { + if (selectedImageUri == null) { + if (photoPicker != null) { + photoPicker.launch(); + } + } else { + selectedImageUri = null; + btnAddScreenshot.setText(feedbackOptions.getAddScreenshotButtonLabel()); + } + }); if (feedbackOptions.isShowBranding()) { imgLogo.setVisibility(View.VISIBLE); @@ -276,7 +312,24 @@ 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(); + final @Nullable Uri imageUri = selectedImageUri; + if (imageUri != null) { + 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), + "screenshot." + ext, + mime, + "event.attachment", + false)); + } + final @NotNull SentryId id = Sentry.feedback().capture(feedback, hint); if (!id.equals(SentryId.EMPTY_ID)) { Toast.makeText( getContext(), feedbackOptions.getSuccessMessageText(), Toast.LENGTH_SHORT) @@ -331,6 +384,7 @@ protected void onStart() { edtMessage.setError(null); final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + maybeRegisterPhotoPicker(options); final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); if (onFormOpen != null) { @@ -340,6 +394,96 @@ protected void onStart() { currentReplayId = options.getReplayController().getReplayId(); } + @Override + protected void onStop() { + super.onStop(); + if (photoPicker != null) { + photoPicker.unregister(); + photoPicker = null; + } + } + + private void maybeRegisterPhotoPicker(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.isEnableScreenshot()) { + return; + } + final @Nullable Activity activity = getActivity(getContext()); + if (activity != null && SentryFeedbackPhotoPicker.isAvailable(loadClass, options)) { + photoPicker = + SentryFeedbackPhotoPicker.register( + activity, uri -> onImagePicked(options, btnAddScreenshot, uri)); + } + if (photoPicker != 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."); + } + } + + private void onImagePicked( + final @NotNull SentryOptions options, + final @NotNull Button btnAddScreenshot, + final @Nullable Uri uri) { + if (uri == null) { + return; + } + final long size = getUriSize(getContext().getContentResolver(), uri); + if (size > options.getMaxAttachmentSize()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Selected image is larger than the maxAttachmentSize of %d bytes, dropping it.", + options.getMaxAttachmentSize()); + Toast.makeText(getContext(), "Image is too large", Toast.LENGTH_SHORT).show(); + return; + } + selectedImageUri = uri; + btnAddScreenshot.setText(resolvedFeedbackOptions.getRemoveScreenshotButtonLabel()); + } + + private static long getUriSize(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 >= 0 && !cursor.isNull(sizeIndex)) { + return cursor.getLong(sizeIndex); + } + } + } catch (Throwable ignored) { + // if the size cannot be determined, let the attachment size limit handle it at capture time + } + return -1; + } + + private static byte[] readUriBytes( + final @NotNull ContentResolver resolver, final @NotNull Uri uri) throws IOException { + try (final @Nullable InputStream inputStream = resolver.openInputStream(uri)) { + if (inputStream == null) { + throw new IOException("Unable to open image attachment: " + uri); + } + final @NotNull ByteArrayOutputStream output = new ByteArrayOutputStream(); + final byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return output.toByteArray(); + } + } + @Override public void show() { // If Sentry is disabled, don't show the dialog, but log a warning 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 index 370c37fa0e9..acffdf0fc90 100644 --- 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 @@ -94,6 +94,16 @@ android:paddingHorizontal="8dp" android:layout_below="@id/sentry_dialog_user_feedback_txt_description" /> +