frames = stacktrace.getFrames();
+ if (frames != null && !frames.isEmpty()) {
+ for (final SentryStackFrame frame : frames) {
+ if (frame.isInApp() != null && frame.isInApp()) {
+ return false;
+ }
+ final String module = frame.getModule();
+ if (module != null && !AnrCulpritIdentifier.isSystemFrame(module)) {
+ return false;
+ }
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Creates debug metadata for an ANR profile chunk using the build metadata selected for the ANR
+ * event.
+ *
+ * ANR profile chunks are captured after app relaunch. If the app was updated between the ANR
+ * and the relaunch, the current options may contain the new build's ProGuard UUID. The provided
+ * {@link OptionsSource} lets us resolve the profile chunk and ANR event to the same originating
+ * build.
+ */
+ private @Nullable DebugMeta createAnrProfileDebugMeta(
+ final @NotNull OptionsSource optionsSource) {
+ final String proguardUuid =
+ getBuildOption(
+ PROGUARD_UUID_FILENAME, String.class, options.getProguardUuid(), optionsSource);
+ if (proguardUuid == null) {
+ // If no historical UUID is available, let the generic profile chunk pipeline apply the
+ // current options UUID as its normal best-effort fallback.
+ return null;
+ }
+
+ final DebugMeta debugMeta = new DebugMeta();
+ debugMeta.setImages(Collections.singletonList(createProguardDebugImage(proguardUuid)));
+ return debugMeta;
+ }
+ }
+
+ private static @NotNull DebugImage createProguardDebugImage(final @NotNull String proguardUuid) {
+ final DebugImage debugImage = new DebugImage();
+ debugImage.setType(DebugImage.PROGUARD);
+ debugImage.setUuid(proguardUuid);
+ return debugImage;
}
}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java
index f4998240f81..7b21c2e392c 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationNotResponding.java
@@ -17,7 +17,12 @@
final class ApplicationNotResponding extends RuntimeException {
private static final long serialVersionUID = 252541144579117016L;
- private final @NotNull Thread thread;
+ private final @Nullable Thread thread;
+
+ ApplicationNotResponding(final @Nullable String message) {
+ super(message);
+ this.thread = null;
+ }
ApplicationNotResponding(final @Nullable String message, final @NotNull Thread thread) {
super(message);
@@ -25,7 +30,7 @@ final class ApplicationNotResponding extends RuntimeException {
setStackTrace(this.thread.getStackTrace());
}
- public @NotNull Thread getThread() {
+ public @Nullable Thread getThread() {
return thread;
}
}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java
index 14cafab224d..83f892573e4 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java
@@ -27,6 +27,7 @@
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadFactory;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;
@@ -56,7 +57,8 @@ public DefaultAndroidEventProcessor(
// noinspection Convert2MethodRef
// some device info performs disk I/O, but it's result is cached, let's pre-cache it
@Nullable Future deviceInfoUtil;
- final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor();
+ final @NotNull ExecutorService executorService =
+ Executors.newSingleThreadExecutor(new DeviceInfoCacheThreadFactory());
try {
deviceInfoUtil =
executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options));
@@ -174,7 +176,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) {
// userId should be set even if event is Cached as the userId is static and won't change anyway.
if (user.getId() == null) {
- user.setId(options.getRuntimeManager().runWithRelaxedPolicy(() -> Installation.id(context)));
+ user.setId(Installation.id(context));
}
if (user.getIpAddress() == null && options.isSendDefaultPii()) {
user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS);
@@ -372,7 +374,7 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) {
*/
public @NotNull User getDefaultUser(final @NotNull Context context) {
final @NotNull User user = new User();
- user.setId(options.getRuntimeManager().runWithRelaxedPolicy(() -> Installation.id(context)));
+ user.setId(Installation.id(context));
return user;
}
@@ -425,4 +427,13 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) {
public @Nullable Long getOrder() {
return 8000L;
}
+
+ private static final class DeviceInfoCacheThreadFactory implements ThreadFactory {
+ @Override
+ public @NotNull Thread newThread(final @NotNull Runnable r) {
+ final Thread ret = new Thread(r, "SentryDeviceInfoCache");
+ ret.setDaemon(true);
+ return ret;
+ }
+ }
}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java
index 5c06a558103..63b88c0e440 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java
@@ -232,27 +232,21 @@ private void setDeviceIO(
// this way of getting the size of storage might be problematic for storages bigger than 2GB
// check the use of
// https://developer.android.com/reference/java/io/File.html#getFreeSpace%28%29
- options
- .getRuntimeManager()
- .runWithRelaxedPolicy(
- () -> {
- final @Nullable File dataDir = Environment.getDataDirectory();
- if (dataDir != null) {
- StatFs internalStorageStat = new StatFs(dataDir.getPath());
- device.setStorageSize(getTotalInternalStorage(internalStorageStat));
- device.setFreeStorage(getUnusedInternalStorage(internalStorageStat));
- }
-
- if (includeExternalStorage) {
- final @Nullable File internalStorageFile = context.getExternalFilesDir(null);
- final @Nullable StatFs externalStorageStat =
- getExternalStorageStat(internalStorageFile);
- if (externalStorageStat != null) {
- device.setExternalStorageSize(getTotalExternalStorage(externalStorageStat));
- device.setExternalFreeStorage(getUnusedExternalStorage(externalStorageStat));
- }
- }
- });
+ final @Nullable File dataDir = Environment.getDataDirectory();
+ if (dataDir != null) {
+ StatFs internalStorageStat = new StatFs(dataDir.getPath());
+ device.setStorageSize(getTotalInternalStorage(internalStorageStat));
+ device.setFreeStorage(getUnusedInternalStorage(internalStorageStat));
+ }
+
+ if (includeExternalStorage) {
+ final @Nullable File internalStorageFile = context.getExternalFilesDir(null);
+ final @Nullable StatFs externalStorageStat = getExternalStorageStat(internalStorageFile);
+ if (externalStorageStat != null) {
+ device.setExternalStorageSize(getTotalExternalStorage(externalStorageStat));
+ device.setExternalFreeStorage(getUnusedExternalStorage(externalStorageStat));
+ }
+ }
if (device.getConnectionType() == null) {
// wifi, ethernet or cellular, null if none
@@ -263,14 +257,19 @@ private void setDeviceIO(
@SuppressWarnings("NewApi")
@NotNull
private TimeZone getTimeZone() {
- if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) {
+ // Only use the costly Calendar API on Android 13+ (API Level 33+) when the locale contains a
+ // Unicode timezone extension (for example "en-US-u-tz-usnyc"), because Calendar honors that
+ // extension. For all other cases, use the process default timezone directly for performance.
+ if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) {
LocaleList locales = context.getResources().getConfiguration().getLocales();
if (!locales.isEmpty()) {
Locale locale = locales.get(0);
- return Calendar.getInstance(locale).getTimeZone();
+ if (locale.getUnicodeLocaleType("tz") != null) {
+ return Calendar.getInstance(locale).getTimeZone();
+ }
}
}
- return Calendar.getInstance().getTimeZone();
+ return TimeZone.getDefault();
}
@SuppressWarnings("JdkObsolete")
@@ -493,7 +492,7 @@ private Long getUnusedExternalStorage(final @NotNull StatFs stat) {
@Nullable
private String getDeviceId() {
try {
- return options.getRuntimeManager().runWithRelaxedPolicy(() -> Installation.id(context));
+ return Installation.id(context);
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Error getting installationId.", e);
}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java
index 482d90c6e6c..ab95ae32daa 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java
@@ -10,8 +10,10 @@
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.util.AutoClosableReentrantLock;
+import io.sentry.util.FileUtils;
import io.sentry.util.Objects;
import java.io.Closeable;
+import java.io.File;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;
@@ -67,6 +69,12 @@ private void startOutboxSender(
final @NotNull IScopes scopes,
final @NotNull SentryOptions options,
final @NotNull String path) {
+ // Create the outbox dir here (on the executor) so the observer can watch it for envelopes
+ // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs.
+ if (!FileUtils.createDirectory(new File(path))) {
+ options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path);
+ }
+
final OutboxSender outboxSender =
new OutboxSender(
scopes,
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java
new file mode 100644
index 00000000000..4405cd19309
--- /dev/null
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/FeedbackShakeIntegration.java
@@ -0,0 +1,314 @@
+package io.sentry.android.core;
+
+import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.Dialog;
+import android.os.Bundle;
+import io.sentry.IScopes;
+import io.sentry.Integration;
+import io.sentry.SentryFeedbackOptions;
+import io.sentry.SentryLevel;
+import io.sentry.SentryOptions;
+import io.sentry.util.Objects;
+import java.io.Closeable;
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.TestOnly;
+
+/**
+ * Detects shake gestures and shows the user feedback dialog when a shake is detected. {@link
+ * io.sentry.SentryFeedbackOptions#isUseShakeGesture()} determines the initial state; it can be
+ * toggled at runtime via {@code Sentry.feedback().enableOnShake()} and {@code
+ * Sentry.feedback().disableOnShake()}.
+ *
+ * Shake detection is scoped to the resumed activity: a dialog belongs to the window of the
+ * activity that created it, so it can only ever be visible while that activity is resumed. Dialogs
+ * report themselves via {@link #onDialogVisible(Activity, Dialog)} / {@link #onDialogGone(Dialog)}
+ * and detection is then suppressed for the activity hosting them, which keeps a shake from stacking
+ * a second dialog on top of a visible one without letting a dialog on a backgrounded activity
+ * suppress detection elsewhere.
+ */
+public final class FeedbackShakeIntegration
+ implements Integration,
+ Closeable,
+ Application.ActivityLifecycleCallbacks,
+ SentryFeedbackOptions.IShakeController {
+
+ private final @NotNull Application application;
+ private final @NotNull SentryShakeDetector shakeDetector;
+ private @Nullable SentryAndroidOptions options;
+ private volatile boolean enabled = false;
+ private volatile @Nullable WeakReference currentActivityRef;
+
+ /**
+ * The feedback dialogs that are currently visible, together with the activity hosting them. More
+ * than one can be visible at a time, e.g. when the app calls {@code Sentry.feedback().show()}
+ * while another dialog is already showing.
+ */
+ private final @NotNull CopyOnWriteArrayList visibleDialogs =
+ new CopyOnWriteArrayList<>();
+
+ public FeedbackShakeIntegration(final @NotNull Application application) {
+ this.application = Objects.requireNonNull(application, "Application is required");
+ this.shakeDetector = new SentryShakeDetector(io.sentry.NoOpLogger.getInstance());
+ }
+
+ @Override
+ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions sentryOptions) {
+ this.options =
+ Objects.requireNonNull(
+ (sentryOptions instanceof SentryAndroidOptions)
+ ? (SentryAndroidOptions) sentryOptions
+ : null,
+ "SentryAndroidOptions is required");
+
+ final @NotNull SentryAndroidOptions options = this.options;
+
+ // Always expose the runtime toggle, even when the option starts out disabled.
+ options.getFeedbackOptions().setShakeController(this);
+
+ if (options.getFeedbackOptions().isUseShakeGesture()) {
+ enableOnShake();
+ }
+ }
+
+ @Override
+ public synchronized void enableOnShake() {
+ final @Nullable SentryAndroidOptions options = this.options;
+ if (enabled || options == null) {
+ return;
+ }
+ enabled = true;
+
+ // Re-arm the detector in case it was closed before, either by disableOnShake() or by a previous
+ // close() (e.g. a second Sentry.init reusing the same options), otherwise the closed latch
+ // would keep shake detection off permanently.
+ shakeDetector.reopen();
+
+ // Resolving the accelerometer is the most expensive part of init (the first SensorManager
+ // access), so warm it up off the main thread. start() re-runs init() on demand, so shake
+ // detection still works if an activity resumes before this completes.
+ try {
+ options
+ .getExecutorService()
+ .submit(() -> shakeDetector.init(application, options.getLogger()));
+ } catch (Throwable t) {
+ options
+ .getLogger()
+ .log(SentryLevel.WARNING, "Failed to submit shake detector initialization.", t);
+ }
+
+ addIntegrationToSdkVersion("FeedbackShake");
+ application.registerActivityLifecycleCallbacks(this);
+ options.getLogger().log(SentryLevel.DEBUG, "FeedbackShakeIntegration installed.");
+
+ // In case of a deferred init or runtime enable, hook into any already-resumed activity
+ final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
+ if (activity != null) {
+ currentActivityRef = new WeakReference<>(activity);
+ startShakeDetection(activity);
+ }
+ }
+
+ @Override
+ public synchronized void disableOnShake() {
+ if (!enabled) {
+ return;
+ }
+ enabled = false;
+
+ application.unregisterActivityLifecycleCallbacks(this);
+ shakeDetector.close();
+ currentActivityRef = null;
+ }
+
+ @Override
+ public boolean isOnShakeEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Reports a feedback dialog as visible on {@code host}. Shake detection is suppressed for that
+ * activity until the dialog reports back via {@link #onDialogGone(Dialog)}, so a shake can never
+ * stack a second dialog on top of a visible one — no matter how the visible one was opened.
+ */
+ void onDialogVisible(final @NotNull Activity host, final @NotNull Dialog dialog) {
+ visibleDialogs.add(new VisibleDialog(host, dialog));
+ stopShakeDetection();
+ }
+
+ /** Reports a feedback dialog as no longer visible. Safe to call more than once per dialog. */
+ void onDialogGone(final @NotNull Dialog dialog) {
+ if (!removeDialog(dialog)) {
+ return;
+ }
+ final @Nullable WeakReference currentRef = currentActivityRef;
+ final @Nullable Activity current = currentRef == null ? null : currentRef.get();
+ if (enabled && current != null) {
+ startShakeDetection(current);
+ }
+ }
+
+ private boolean removeDialog(final @NotNull Dialog dialog) {
+ boolean removed = false;
+ for (final @NotNull VisibleDialog visibleDialog : visibleDialogs) {
+ // Drop entries whose dialog was collected without reporting back, so they can't suppress
+ // detection forever.
+ final @Nullable Dialog trackedDialog = visibleDialog.dialogRef.get();
+ if (trackedDialog == dialog) {
+ removed = visibleDialogs.remove(visibleDialog) || removed;
+ } else if (trackedDialog == null) {
+ visibleDialogs.remove(visibleDialog);
+ }
+ }
+ return removed;
+ }
+
+ private boolean hasDialogOn(final @NotNull Activity activity) {
+ for (final @NotNull VisibleDialog visibleDialog : visibleDialogs) {
+ if (visibleDialog.dialogRef.get() != null && visibleDialog.activityRef.get() == activity) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @TestOnly
+ @Nullable
+ Activity getDialogActivity() {
+ for (final @NotNull VisibleDialog visibleDialog : visibleDialogs) {
+ if (visibleDialog.dialogRef.get() != null) {
+ return visibleDialog.activityRef.get();
+ }
+ }
+ return null;
+ }
+
+ /** Creates the dialog shown on shake. Replaceable in tests to simulate a failing show(). */
+ interface DialogFactory {
+ @NotNull
+ Dialog create(final @NotNull Activity activity);
+ }
+
+ private @NotNull DialogFactory dialogFactory =
+ activity -> new SentryUserFeedbackForm.Builder(activity).create();
+
+ @TestOnly
+ void setDialogFactory(final @NotNull DialogFactory dialogFactory) {
+ this.dialogFactory = dialogFactory;
+ }
+
+ private static final class VisibleDialog {
+ private final @NotNull WeakReference activityRef;
+ private final @NotNull WeakReference