Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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<Activity> 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<Activity> activityRef;

ShakeLifecycleCallbacks(final @NotNull WeakReference<Activity> 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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@
<meta-data
android:name="io.sentry.anr.enable-fingerprinting"
android:value="true" />
<!-- Enable feedback on shake globally -->
<meta-data
android:name="io.sentry.feedback.use-shake-gesture"
android:value="true" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
}
}
}

Expand Down
Loading