Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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();
}
});
}
});
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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")
}
}
Comment thread
romtsn marked this conversation as resolved.
Comment thread
romtsn marked this conversation as resolved.
}
}

Expand Down
Loading