diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd2e8d0f78..110c7feccb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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 + ### Improvements - Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790)) 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 23248d6dae4..75b77752349 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -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) @@ -115,6 +118,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) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 4cd76f9a20d..16fbced1577 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 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 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 469f15e3f3b..a507ffb6929 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,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"; @@ -723,6 +726,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( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackScreenshotPicker.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackScreenshotPicker.java new file mode 100644 index 00000000000..ffb8fdc2414 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFeedbackScreenshotPicker.java @@ -0,0 +1,69 @@ +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 screenshot 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 SentryFeedbackScreenshotPicker { + + interface OnScreenshotPicked { + void onScreenshotPicked(@NotNull Uri uri); + } + + private final @NotNull ActivityResultLauncher launcher; + + private SentryFeedbackScreenshotPicker( + final @NotNull ActivityResultLauncher launcher) { + this.launcher = launcher; + } + + 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); + } + + static @Nullable SentryFeedbackScreenshotPicker register( + final @NotNull Activity activity, + final @NotNull SentryFeedbackScreenshotPicker.OnScreenshotPicked callback) { + if (!(activity instanceof ComponentActivity)) { + return null; + } + final @NotNull ActivityResultLauncher 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); + } + + 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..cbf11a036a6 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,9 @@ import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.FileUtils; +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 +49,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, @@ -191,6 +204,29 @@ 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 (Throwable t) { + 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); @@ -276,7 +312,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) @@ -331,6 +369,7 @@ protected void onStart() { edtMessage.setError(null); final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + maybeRegisterScreenshotPicker(options); final @NotNull SentryFeedbackOptions feedbackOptions = options.getFeedbackOptions(); final @Nullable Runnable onFormOpen = feedbackOptions.getOnFormOpen(); if (onFormOpen != null) { @@ -340,6 +379,125 @@ protected void onStart() { currentReplayId = options.getReplayController().getReplayId(); } + @Override + protected void onStop() { + super.onStop(); + if (screenshotPicker != null) { + screenshotPicker.unregister(); + screenshotPicker = null; + } + } + + 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 + && SentryFeedbackScreenshotPicker.isAvailable( + resolvedFeedbackOptions.getLoadClass(), options)) { + screenshotPicker = + SentryFeedbackScreenshotPicker.register( + activity, uri -> onScreenshotPicked(options, btnAddScreenshot, uri)); + } + 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."); + } + } + + private void onScreenshotPicked( + final @NotNull SentryOptions options, + final @NotNull Button btnAddScreenshot, + final @NotNull Uri uri) { + final long size = getUriSize(getContext().getContentResolver(), uri); + if (size > options.getMaxAttachmentSize()) { + 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)); + } catch (Throwable t) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to attach image to feedback.", t); + } + } + + 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 != -1 && !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, 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 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" /> +