diff --git a/CHANGELOG.md b/CHANGELOG.md index beabdfb7f15..75481a86715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ - All deprecated APIs will be removed in the next major version - Deprecate `SentryUserFeedbackButton` (View-based and Compose-based) ([#5350](https://github.com/getsentry/sentry-java/pull/5350)) - It will be removed in the next major version +- Add per-form shake-to-show support for `SentryUserFeedbackForm` ([#5353](https://github.com/getsentry/sentry-java/pull/5353)) + - Useful for enabling shake-to-report on specific screens instead of globally + ```kotlin + SentryUserFeedbackForm.Builder(activity) + .configurator { it.isUseShakeGesture = true } + .create() + ``` ### Dependencies 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 0babe475491..4e32cd76b2a 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 @@ -1,7 +1,10 @@ package io.sentry.android.core; +import android.app.Activity; import android.app.AlertDialog; +import android.app.Application; import android.content.Context; +import android.content.ContextWrapper; import android.os.Bundle; import android.view.View; import android.widget.Button; @@ -18,6 +21,7 @@ import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import java.lang.ref.WeakReference; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -28,8 +32,10 @@ public class SentryUserFeedbackForm extends AlertDialog { private final @Nullable SentryId associatedEventId; private @Nullable OnDismissListener delegate; - private final @Nullable OptionsConfiguration configuration; - private final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator; + private final @NotNull SentryFeedbackOptions resolvedFeedbackOptions; + + private @Nullable SentryShakeDetector shakeDetector; + private @Nullable Application.ActivityLifecycleCallbacks shakeLifecycleCallbacks; SentryUserFeedbackForm( final @NotNull Context context, @@ -39,9 +45,127 @@ public class SentryUserFeedbackForm extends AlertDialog { final @Nullable SentryFeedbackOptions.OptionsConfigurator configurator) { super(context, themeResId); this.associatedEventId = associatedEventId; - this.configuration = configuration; - this.configurator = configurator; + this.resolvedFeedbackOptions = + new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); + if (configuration != null) { + configuration.configure(context, resolvedFeedbackOptions); + } + if (configurator != null) { + configurator.configure(resolvedFeedbackOptions); + } SentryIntegrationPackageStorage.getInstance().addIntegration("UserFeedbackWidget"); + maybeStartShakeDetection(context); + } + + private void maybeStartShakeDetection(final @NotNull Context context) { + final @NotNull SentryFeedbackOptions globalFeedbackOptions = + Sentry.getCurrentScopes().getOptions().getFeedbackOptions(); + if (!resolvedFeedbackOptions.isUseShakeGesture() || globalFeedbackOptions.isUseShakeGesture()) { + return; + } + final @Nullable Activity activity = getActivity(context); + if (activity == null) { + return; + } + final @NotNull SentryOptions options = Sentry.getCurrentScopes().getOptions(); + shakeDetector = new SentryShakeDetector(options.getLogger()); + final @NotNull WeakReference activityRef = new WeakReference<>(activity); + shakeDetector.start( + activity, + () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }); + final @NotNull Application app = activity.getApplication(); + shakeLifecycleCallbacks = new ShakeLifecycleCallbacks(activityRef); + app.registerActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + + private void stopShakeDetection() { + if (shakeDetector != null) { + shakeDetector.close(); + shakeDetector = null; + } + if (shakeLifecycleCallbacks != null) { + final @Nullable Activity activity = getActivity(getContext()); + if (activity != null) { + activity.getApplication().unregisterActivityLifecycleCallbacks(shakeLifecycleCallbacks); + } + shakeLifecycleCallbacks = null; + } + } + + private static @Nullable Activity getActivity(final @NotNull Context context) { + Context current = context; + while (current instanceof ContextWrapper) { + if (current instanceof Activity) { + return (Activity) current; + } + current = ((ContextWrapper) current).getBaseContext(); + } + return null; + } + + private class ShakeLifecycleCallbacks implements Application.ActivityLifecycleCallbacks { + private final @NotNull WeakReference activityRef; + + ShakeLifecycleCallbacks(final @NotNull WeakReference activityRef) { + this.activityRef = activityRef; + } + + @Override + public void onActivityResumed(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.start( + activity, + () -> { + final @Nullable Activity active = activityRef.get(); + if (active != null && !active.isFinishing() && !active.isDestroyed()) { + active.runOnUiThread( + () -> { + if (!active.isFinishing() && !active.isDestroyed()) { + show(); + } + }); + } + }); + } + } + + @Override + public void onActivityPaused(final @NotNull Activity activity) { + if (activity == activityRef.get() && shakeDetector != null) { + shakeDetector.stop(); + } + } + + @Override + public void onActivityDestroyed(final @NotNull Activity activity) { + if (activity == activityRef.get()) { + stopShakeDetection(); + } + } + + @Override + public void onActivityCreated( + final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(final @NotNull Activity activity) {} + + @Override + public void onActivityStopped(final @NotNull Activity activity) {} + + @Override + public void onActivitySaveInstanceState( + final @NotNull Activity activity, final @NotNull Bundle outState) {} } @Override @@ -57,14 +181,7 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.sentry_dialog_user_feedback); setCancelable(isCancelable); - final @NotNull SentryFeedbackOptions feedbackOptions = - new SentryFeedbackOptions(Sentry.getCurrentScopes().getOptions().getFeedbackOptions()); - if (configuration != null) { - configuration.configure(getContext(), feedbackOptions); - } - if (configurator != null) { - configurator.configure(feedbackOptions); - } + final @NotNull SentryFeedbackOptions feedbackOptions = resolvedFeedbackOptions; final @NotNull TextView lblTitle = findViewById(R.id.sentry_dialog_user_feedback_title); final @NotNull ImageView imgLogo = findViewById(R.id.sentry_dialog_user_feedback_logo); final @NotNull TextView lblName = findViewById(R.id.sentry_dialog_user_feedback_txt_name); diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 548e5e8ac0d..26f526124b4 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -271,6 +271,7 @@ + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 4c4ef05fb1a..e000b54e4cc 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -62,6 +63,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -79,8 +81,9 @@ import io.sentry.MeasurementUnit import io.sentry.Sentry import io.sentry.SentryLogLevel import io.sentry.UpdateStatus +import io.sentry.android.core.SentryUserFeedbackForm import io.sentry.compose.SentryTraced -import io.sentry.compose.SentryUserFeedbackButton +import io.sentry.protocol.Feedback import io.sentry.protocol.User import java.io.File import java.io.FileOutputStream @@ -615,8 +618,106 @@ fun UserFeedbackScreen() { } } - // SentryUserFeedbackButton as a special item - item(span = { GridItemSpan(maxLineSpan) }) { SentryUserFeedbackButton(modifier = Modifier) } + // Bring up User Feedback Form from a custom button using the global Sentry.feedback() API + item(span = { GridItemSpan(maxLineSpan) }) { + Button(modifier = Modifier, onClick = { Sentry.feedback().show() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = + painterResource( + id = io.sentry.compose.R.drawable.sentry_user_feedback_compose_button_logo_24 + ), + contentDescription = null, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(text = "Report a Bug") + } + } + } + + // Create a SentryUserFeedbackForm programmatically and show it + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.formTitle = "Custom Form" + options.submitButtonLabel = "Send" + options.cancelButtonLabel = "Never mind" + options.messageLabel = "What happened?" + options.messagePlaceholder = "Describe the issue..." + options.isShowBranding = false + options.isNameRequired = true + options.isEmailRequired = true + options.setOnSubmitSuccess { feedback -> + Toast.makeText(activity, "Thanks for the feedback!", Toast.LENGTH_SHORT).show() + } + } + .create() + .show() + }, + ) { + Text(text = "Custom Form (Builder)") + } + } + + // Showcases how to manually show and dismiss a form programmatically + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val form = + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> options.formTitle = "Quick! You have 2 seconds" } + .create() + form.show() + Handler(Looper.getMainLooper()).postDelayed({ form.dismiss() }, 2000) + }, + ) { + Text(text = "Auto-dismiss Form (2s)") + } + } + + // Send feedback programmatically without showing a form + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + val feedback = + Feedback("The app crashed when I tapped the button").apply { + name = "Jane Doe" + contactEmail = "jane@example.com" + url = "https://example.com/page" + } + val eventId = Sentry.feedback().capture(feedback) + Toast.makeText(activity, "Feedback sent: $eventId", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Send Feedback (no form)") + } + } + + // Enable shake-to-show for a specific form instance + item(span = { GridItemSpan(maxLineSpan) }) { + Button( + modifier = Modifier, + onClick = { + SentryUserFeedbackForm.Builder(activity) + .configurator { options -> + options.isUseShakeGesture = true + options.formTitle = "Shake Feedback" + } + .create() + Toast.makeText(activity, "Shake your device to open the form!", Toast.LENGTH_SHORT).show() + }, + ) { + Text(text = "Enable Shake-to-Show") + } + } } }