diff --git a/CHANGELOG.md b/CHANGELOG.md index 3857217a987..bf658ced7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,134 @@ # Changelog +## Unreleased + +### Fixes + +- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) +- Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) +- Make `SystemEventsBreadcrumbsIntegration` faster ([#4330](https://github.com/getsentry/sentry-java/pull/4330)) +- Fix unregister `SystemEventsBroadcastReceiver` when entering background ([#4338](https://github.com/getsentry/sentry-java/pull/4338)) + - This should reduce ANRs seen with this class in the stack trace for Android 14 and above +- Pre-load modules on a background thread upon SDK init ([#4348](https://github.com/getsentry/sentry-java/pull/4348)) +- Session Replay: Fix inconsistent `segment_id` ([#4471](https://github.com/getsentry/sentry-java/pull/4471)) +- Session Replay: Do not capture current replay for cached events from the past ([#4474](https://github.com/getsentry/sentry-java/pull/4474)) +- Session Replay: Fix crash on devices with the Unisoc/Spreadtrum T606 chipset ([#4477](https://github.com/getsentry/sentry-java/pull/4477)) +- Session Replay: Fix masking of non-styled `Text` Composables ([#4361](https://github.com/getsentry/sentry-java/pull/4361)) +- Session Replay: Fix masking read-only `TextField` Composables ([#4362](https://github.com/getsentry/sentry-java/pull/4362)) +- Correctly capture Dialogs and non full-sized windows ([#4354](https://github.com/getsentry/sentry-java/pull/4354)) +- Fix Session Replay masking for newer versions of Jetpack Compose (1.8+) ([#4485](https://github.com/getsentry/sentry-java/pull/4485)) +- Session Replay: Expand fix for crash on devices to all Unisoc/Spreadtrum chipsets ([#4510](https://github.com/getsentry/sentry-java/pull/4510)) +- Session Replay: Fix `IllegalArgumentException` when `Bitmap` is initialized with non-positive values ([#4536](https://github.com/getsentry/sentry-java/pull/4536)) + +## 7.22.5 + +### Fixes + +- Session Replay: Change bitmap config to `ARGB_8888` for screenshots ([#4282](https://github.com/getsentry/sentry-java/pull/4282)) + +## 7.22.4 + +### Fixes + +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Avoid logging an error when a float is passed in the manifest ([#4266](https://github.com/getsentry/sentry-java/pull/4266)) + +## 7.22.3 + +### Fixes + +- Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) + +## 7.22.2 + +### Fixes + +- Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4256](https://github.com/getsentry/sentry-java/pull/4256)) + +## 7.22.1 + +### Fixes + +- Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) +- Fix properly reset application/content-provider timespans for warm app starts ([#4244](https://github.com/getsentry/sentry-java/pull/4244)) + +## 7.22.0 + +### Fixes + +- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135)) + - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file + - Fix `IllegalStateException` when registering `onDrawListener` + - Fix SIGABRT native crashes on Motorola devices when encoding a video +- (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) + - This allows Composables that use this modifier to be skippable + +### Features + +- (Internal) Add API to filter native debug images based on stacktrace addresses ([#4159](https://github.com/getsentry/sentry-java/pull/4159)) + +## 7.21.0 + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + +## 7.21.0-beta.1 + +### Fixes + +- Do not instrument File I/O operations if tracing is disabled ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Do not instrument User Interaction multiple times ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Speed up view traversal to find touched target in `UserInteractionIntegration` ([#4051](https://github.com/getsentry/sentry-java/pull/4051)) +- Reduce IPC/Binder calls performed by the SDK ([#4058](https://github.com/getsentry/sentry-java/pull/4058)) + +### Behavioural Changes + +- Reduce the number of broadcasts the SDK is subscribed for ([#4052](https://github.com/getsentry/sentry-java/pull/4052)) + - Drop `TempSensorBreadcrumbsIntegration` + - Drop `PhoneStateBreadcrumbsIntegration` + - Reduce number of broadcasts in `SystemEventsBreadcrumbsIntegration` + +Current list of the broadcast events can be found [here](https://github.com/getsentry/sentry-java/blob/9b8dc0a844d10b55ddeddf55d278c0ab0f86421c/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java#L131-L153). If you'd like to subscribe for more events, consider overriding the `SystemEventsBreadcrumbsIntegration` as follows: + +```kotlin +SentryAndroid.init(context) { options -> + options.integrations.removeAll { it is SystemEventsBreadcrumbsIntegration } + options.integrations.add(SystemEventsBreadcrumbsIntegration(context, SystemEventsBreadcrumbsIntegration.getDefaultActions() + listOf(/* your custom actions */))) +} +``` + +If you would like to keep some of the default broadcast events as breadcrumbs, consider opening a [GitHub issue](https://github.com/getsentry/sentry-java/issues/new). + +## 7.20.1 + +### Behavioural Changes + +- The user ip-address is now only set to `"{{auto}}"` if sendDefaultPii is enabled ([#4071](https://github.com/getsentry/sentry-java/pull/4071)) + - This change gives you control over IP address collection directly on the client + ## 7.20.0 ### Features diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index bf5dba03be5..26c6e9775b1 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -201,6 +201,8 @@ object Config { val javaFaker = "com.github.javafaker:javafaker:1.0.2" val msgpack = "org.msgpack:msgpack-core:0.9.8" val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" + val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$composeVersion" + val okio = "com.squareup.okio:okio:1.13.0" } object QualityPlugins { diff --git a/gradle.properties b/gradle.properties index 65fe48ea942..40ebe6be17b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.20.0 +versionName=7.22.5 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 02725ba5df6..8096a5058e3 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -193,6 +193,7 @@ public final class io/sentry/android/core/DeviceInfoUtil { public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo; + public fun getTotalMemory ()Ljava/lang/Long; public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; public static fun resetInstance ()V } @@ -207,6 +208,7 @@ public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : i public abstract interface class io/sentry/android/core/IDebugImagesLoader { public abstract fun clearDebugImages ()V public abstract fun loadDebugImages ()Ljava/util/List; + public abstract fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; } public final class io/sentry/android/core/InternalSentrySdk { @@ -245,12 +247,6 @@ public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sen public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/PhoneStateBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; @@ -379,14 +375,7 @@ public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : i public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - -public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : android/hardware/SensorEventListener, io/sentry/Integration, java/io/Closeable { - public fun (Landroid/content/Context;)V - public fun close ()V - public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V - public fun onSensorChanged (Landroid/hardware/SensorEvent;)V + public static fun getDefaultActions ()Ljava/util/List; public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } @@ -458,15 +447,15 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z - public fun isColdStartValid ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityStarted (Landroid/app/Activity;)V public fun onAppStartSpansSent ()V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V - public fun registerApplicationForegroundCheck (Landroid/app/Application;)V - public fun restartAppStart (J)V + public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V @@ -514,3 +503,14 @@ public class io/sentry/android/core/performance/WindowContentChangedCallback : i public fun onContentChanged ()V } +public final class io/sentry/android/core/util/AndroidLazyEvaluator { + public fun (Lio/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator;)V + public fun getValue (Landroid/content/Context;)Ljava/lang/Object; + public fun resetValue ()V + public fun setValue (Ljava/lang/Object;)V +} + +public abstract interface class io/sentry/android/core/util/AndroidLazyEvaluator$AndroidEvaluator { + public abstract fun evaluate (Landroid/content/Context;)Ljava/lang/Object; +} + diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 0912051dd7d..5ddf706d16d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -397,7 +397,6 @@ public synchronized void onActivityCreated( if (!isAllActivityCallbacksAvailable) { onActivityPreCreated(activity, savedInstanceState); } - setColdStart(savedInstanceState); if (hub != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); hub.configureScope(scope -> scope.setScreen(activityClassName)); @@ -554,15 +553,13 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) { // if the activity is opened again and not in memory, transactions will be created normally. activitiesWithOngoingTransactions.remove(activity); - if (activitiesWithOngoingTransactions.isEmpty()) { + if (activitiesWithOngoingTransactions.isEmpty() && !activity.isChangingConfigurations()) { clear(); } } private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - lastPausedUptimeMillis = 0; activityLifecycleMap.clear(); } @@ -705,27 +702,6 @@ WeakHashMap getTtfdSpanMap() { return ttfdSpanMap; } - private void setColdStart(final @Nullable Bundle savedInstanceState) { - if (!firstActivityCreated) { - final @NotNull TimeSpan appStartSpan = AppStartMetrics.getInstance().getAppStartTimeSpan(); - // If the app start span already started and stopped, it means the app restarted without - // killing the process, so we are in a warm start - // If the app has an invalid cold start, it means it was started in the background, like - // via BroadcastReceiver, so we consider it a warm start - if ((appStartSpan.hasStarted() && appStartSpan.hasStopped()) - || (!AppStartMetrics.getInstance().isColdStartValid())) { - AppStartMetrics.getInstance().restartAppStart(lastPausedUptimeMillis); - AppStartMetrics.getInstance().setAppStartType(AppStartMetrics.AppStartType.WARM); - } else { - AppStartMetrics.getInstance() - .setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); - } - } - } - private @NotNull String getTtidDesc(final @NotNull String activityName) { return activityName + " initial display"; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index d5dfce77b28..5378f93110b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -143,6 +143,11 @@ static void initializeIntegrationsAndProcessors( new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider)); } + if (options.getCacheDirPath() != null) { + options.addScopeObserver(new PersistingScopeObserver(options)); + options.addOptionsObserver(new PersistingOptionsObserver(options)); + } + options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); options.addEventProcessor( new DefaultAndroidEventProcessor(context, buildInfoProvider, options)); @@ -221,13 +226,6 @@ static void initializeIntegrationsAndProcessors( } } options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); - - if (options.getCacheDirPath() != null) { - if (options.isEnableScopePersistence()) { - options.addScopeObserver(new PersistingScopeObserver(options)); - } - options.addOptionsObserver(new PersistingOptionsObserver(options)); - } } static void installDefaultIntegrations( @@ -273,6 +271,8 @@ static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it options.addIntegration(new AppLifecycleIntegration()); + // AnrIntegration must be installed before ReplayIntegration, as ReplayIntegration relies on + // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); // registerActivityLifecycleCallbacks is only available if Context is an AppContext @@ -301,8 +301,6 @@ static void installDefaultIntegrations( options.addIntegration(new SystemEventsBreadcrumbsIntegration(context)); options.addIntegration( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); - options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); - options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); if (isReplayAvailable) { final ReplayIntegration replay = new ReplayIntegration(context, CurrentDateProvider.getInstance()); @@ -322,8 +320,8 @@ private static void readDefaultOptionValues( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final @Nullable PackageInfo packageInfo = + ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { // Sets App's release if not set by Manifest if (options.getRelease() == null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index 41e57a886a4..f4aa3cf0987 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -1,10 +1,8 @@ package io.sentry.android.core; -import static android.content.Context.ACTIVITY_SERVICE; import static java.util.concurrent.TimeUnit.SECONDS; import android.annotation.SuppressLint; -import android.app.ActivityManager; import android.content.Context; import android.os.Build; import android.os.Process; @@ -265,9 +263,12 @@ public synchronized void bindTransaction(final @NotNull ITransaction transaction transactionsCounter = 0; String totalMem = "0"; - ActivityManager.MemoryInfo memInfo = getMemInfo(); - if (memInfo != null) { - totalMem = Long.toString(memInfo.totalMem); + final @Nullable Long memory = + (options instanceof SentryAndroidOptions) + ? DeviceInfoUtil.getInstance(context, (SentryAndroidOptions) options).getTotalMemory() + : null; + if (memory != null) { + totalMem = Long.toString(memory); } String[] abis = Build.SUPPORTED_ABIS; @@ -333,27 +334,6 @@ public void close() { } } - /** - * Get MemoryInfo object representing the memory state of the application. - * - * @return MemoryInfo object representing the memory state of the application - */ - private @Nullable ActivityManager.MemoryInfo getMemInfo() { - try { - ActivityManager actManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); - ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); - if (actManager != null) { - actManager.getMemoryInfo(memInfo); - return memInfo; - } - logger.log(SentryLevel.INFO, "Error getting MemoryInfo."); - return null; - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting MemoryInfo.", e); - return null; - } - } - @TestOnly int getTransactionsCounter() { return transactionsCounter; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index e914029c30c..0f07151d2fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -33,6 +33,7 @@ import io.sentry.SentryEvent; import io.sentry.SentryExceptionFactory; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; import io.sentry.android.core.internal.util.CpuInfoUtils; @@ -83,6 +84,8 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable PersistingScopeObserver persistingScopeObserver; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, @@ -90,6 +93,7 @@ public AnrV2EventProcessor( this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; + this.persistingScopeObserver = options.findPersistingScopeObserver(); final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -188,8 +192,7 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { } private void setReplayId(final @NotNull SentryEvent event) { - @Nullable - String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); final @NotNull File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); if (!replayFolder.exists()) { @@ -224,8 +227,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } private void setTrace(final @NotNull SentryEvent event) { - final SpanContext spanContext = - PersistingScopeObserver.read(options, TRACE_FILENAME, SpanContext.class); + final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); if (event.getContexts().getTrace() == null) { if (spanContext != null && spanContext.getSpanId() != null @@ -236,8 +238,7 @@ private void setTrace(final @NotNull SentryEvent event) { } private void setLevel(final @NotNull SentryEvent event) { - final SentryLevel level = - PersistingScopeObserver.read(options, LEVEL_FILENAME, SentryLevel.class); + final SentryLevel level = readFromDisk(options, LEVEL_FILENAME, SentryLevel.class); if (event.getLevel() == null) { event.setLevel(level); } @@ -246,7 +247,7 @@ private void setLevel(final @NotNull SentryEvent event) { @SuppressWarnings("unchecked") private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Object hint) { final List fingerprint = - (List) PersistingScopeObserver.read(options, FINGERPRINT_FILENAME, List.class); + (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); if (event.getFingerprints() == null) { event.setFingerprints(fingerprint); } @@ -262,16 +263,14 @@ private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Ob } private void setTransaction(final @NotNull SentryEvent event) { - final String transaction = - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String.class); + final String transaction = readFromDisk(options, TRANSACTION_FILENAME, String.class); if (event.getTransaction() == null) { event.setTransaction(transaction); } } private void setContexts(final @NotNull SentryBaseEvent event) { - final Contexts persistedContexts = - PersistingScopeObserver.read(options, CONTEXTS_FILENAME, Contexts.class); + final Contexts persistedContexts = readFromDisk(options, CONTEXTS_FILENAME, Contexts.class); if (persistedContexts == null) { return; } @@ -291,7 +290,7 @@ private void setContexts(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setExtras(final @NotNull SentryBaseEvent event) { final Map extras = - (Map) PersistingScopeObserver.read(options, EXTRAS_FILENAME, Map.class); + (Map) readFromDisk(options, EXTRAS_FILENAME, Map.class); if (extras == null) { return; } @@ -309,14 +308,12 @@ private void setExtras(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { final List breadcrumbs = - (List) - PersistingScopeObserver.read( - options, BREADCRUMBS_FILENAME, List.class, new Breadcrumb.Deserializer()); + (List) readFromDisk(options, BREADCRUMBS_FILENAME, List.class); if (breadcrumbs == null) { return; } if (event.getBreadcrumbs() == null) { - event.setBreadcrumbs(new ArrayList<>(breadcrumbs)); + event.setBreadcrumbs(breadcrumbs); } else { event.getBreadcrumbs().addAll(breadcrumbs); } @@ -326,7 +323,7 @@ private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { private void setScopeTags(final @NotNull SentryBaseEvent event) { final Map tags = (Map) - PersistingScopeObserver.read(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); + readFromDisk(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); if (tags == null) { return; } @@ -343,19 +340,29 @@ private void setScopeTags(final @NotNull SentryBaseEvent event) { private void setUser(final @NotNull SentryBaseEvent event) { if (event.getUser() == null) { - final User user = PersistingScopeObserver.read(options, USER_FILENAME, User.class); + final User user = readFromDisk(options, USER_FILENAME, User.class); event.setUser(user); } } private void setRequest(final @NotNull SentryBaseEvent event) { if (event.getRequest() == null) { - final Request request = - PersistingScopeObserver.read(options, REQUEST_FILENAME, Request.class); + final Request request = readFromDisk(options, REQUEST_FILENAME, Request.class); event.setRequest(request); } } + private @Nullable T readFromDisk( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + if (persistingScopeObserver == null) { + return null; + } + + return persistingScopeObserver.read(options, fileName, clazz); + } + // endregion // region options persisted values @@ -374,14 +381,13 @@ private void setApp(final @NotNull SentryBaseEvent event, final @NotNull Object if (app == null) { app = new App(); } - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); // TODO: not entirely correct, because we define background ANRs as not the ones of // IMPORTANCE_FOREGROUND, but this doesn't mean the app was in foreground when an ANR happened // but it's our best effort for now. We could serialize AppState in theory. app.setInForeground(!isBackgroundAnr(hint)); - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, buildInfoProvider); if (packageInfo != null) { app.setAppIdentifier(packageInfo.packageName); } @@ -575,7 +581,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } @@ -592,8 +598,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { try { final ContextUtils.SideLoadedInfo sideLoadedInfo = - ContextUtils.retrieveSideLoadedInfo(context, options.getLogger(), buildInfoProvider); - + DeviceInfoUtil.getInstance(context, options).getSideLoadedInfo(); if (sideLoadedInfo != null) { final @NotNull Map tags = sideLoadedInfo.asTags(); for (Map.Entry entry : tags.entrySet()) { @@ -662,7 +667,8 @@ private void setDevice(final @NotNull SentryBaseEvent event) { private void mergeOS(final @NotNull SentryBaseEvent event) { final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); - final OperatingSystem androidOS = getOperatingSystem(); + final OperatingSystem androidOS = + DeviceInfoUtil.getInstance(context, options).getOperatingSystem(); // make Android OS the main OS using the 'os' key event.getContexts().setOperatingSystem(androidOS); @@ -678,20 +684,5 @@ private void mergeOS(final @NotNull SentryBaseEvent event) { event.getContexts().put(osNameKey, currentOS); } } - - private @NotNull OperatingSystem getOperatingSystem() { - OperatingSystem os = new OperatingSystem(); - os.setName("Android"); - os.setVersion(Build.VERSION.RELEASE); - os.setBuild(Build.DISPLAY); - - try { - os.setKernelVersion(ContextUtils.getKernelVersion(options.getLogger())); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting OperatingSystem.", e); - } - - return os; - } // endregion } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index e11bd5d3b9f..b520475ff93 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -12,6 +12,8 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.protocol.Device; import io.sentry.util.Objects; @@ -24,10 +26,17 @@ public final class AppComponentsBreadcrumbsIntegration implements Integration, Closeable, ComponentCallbacks2 { + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; + // pre-allocate hint to avoid creating it every time for the low memory case + private static final @NotNull Hint EMPTY_HINT = new Hint(); + private final @NotNull Context context; private @Nullable IHub hub; private @Nullable SentryAndroidOptions options; + private final @NotNull Debouncer trimMemoryDebouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); + public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { this.context = Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); @@ -91,42 +100,43 @@ public void onConfigurationChanged(@NotNull Configuration newConfig) { @Override public void onLowMemory() { - final long now = System.currentTimeMillis(); - executeInBackground(() -> captureLowMemoryBreadcrumb(now, null)); + // we do this in onTrimMemory below already, this is legacy API (14 or below) } @Override public void onTrimMemory(final int level) { + if (level < TRIM_MEMORY_BACKGROUND) { + // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or + // TRIM_MEMORY_COMPLETE. + // Release as much memory as the process can. + + // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and + // TRIM_MEMORY_RUNNING_CRITICAL. + // Release any memory that your app doesn't need to run. + // So they are still not so critical at the point of killing the process. + // https://developer.android.com/topic/performance/memory + return; + } + + if (trimMemoryDebouncer.checkForDebounce()) { + // if we received trim_memory within 1 minute time, ignore this call + return; + } + final long now = System.currentTimeMillis(); executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); } - private void captureLowMemoryBreadcrumb(final long timeMs, final @Nullable Integer level) { + private void captureLowMemoryBreadcrumb(final long timeMs, final int level) { if (hub != null) { final Breadcrumb breadcrumb = new Breadcrumb(timeMs); - if (level != null) { - // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or - // TRIM_MEMORY_COMPLETE. - // Release as much memory as the process can. - - // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and - // TRIM_MEMORY_RUNNING_CRITICAL. - // Release any memory that your app doesn't need to run. - // So they are still not so critical at the point of killing the process. - // https://developer.android.com/topic/performance/memory - - if (level < TRIM_MEMORY_BACKGROUND) { - return; - } - breadcrumb.setData("level", level); - } - breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); breadcrumb.setMessage("Low memory"); breadcrumb.setData("action", "LOW_MEMORY"); + breadcrumb.setData("level", level); breadcrumb.setLevel(SentryLevel.WARNING); - hub.addBreadcrumb(breadcrumb); + hub.addBreadcrumb(breadcrumb, EMPTY_HINT); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index f730f4bc76a..d7ff094dba6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -69,9 +69,8 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio options .getLogger() .log( - SentryLevel.INFO, - "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed", - e); + SentryLevel.WARNING, + "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed"); } catch (IllegalStateException e) { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 89fe856631b..086c2a101c7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -2,7 +2,6 @@ import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; import static android.content.Context.ACTIVITY_SERVICE; -import static android.content.Context.RECEIVER_EXPORTED; import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED; import android.annotation.SuppressLint; @@ -20,7 +19,9 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.util.AndroidLazyEvaluator; import io.sentry.protocol.App; +import io.sentry.util.LazyEvaluator; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; @@ -30,6 +31,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class ContextUtils { @@ -63,6 +65,106 @@ public boolean isSideLoaded() { private ContextUtils() {} + // to avoid doing a bunch of Binder calls we use LazyEvaluator to cache the values that are static + // during the app process running + + private static final @NotNull AndroidLazyEvaluator deviceName = + new AndroidLazyEvaluator<>( + (context) -> Settings.Global.getString(context.getContentResolver(), "device_name")); + + private static final @NotNull LazyEvaluator isForegroundImportance = + new LazyEvaluator<>( + () -> { + try { + final ActivityManager.RunningAppProcessInfo appProcessInfo = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(appProcessInfo); + return appProcessInfo.importance == IMPORTANCE_FOREGROUND; + } catch (Throwable ignored) { + // should never happen + } + return false; + }); + + /** + * Since this packageInfo uses flags 0 we can assume it's static and cache it as the package name + * or version code cannot change during runtime, only after app update (which will spin up a new + * process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticPackageInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.PackageInfoFlags.of(0)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticPackageInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator applicationName = + new AndroidLazyEvaluator<>( + context -> { + try { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + final int stringId = applicationInfo.labelRes; + if (stringId == 0) { + if (applicationInfo.nonLocalizedLabel != null) { + return applicationInfo.nonLocalizedLabel.toString(); + } + return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); + } else { + return context.getString(stringId); + } + } catch (Throwable e) { + return null; + } + }); + + /** + * Since this applicationInfo uses the same flag (METADATA) we can assume it's static and cache it + * as the manifest metadata cannot change during runtime, only after app update (which will spin + * up a new process). + */ + @SuppressLint("NewApi") + private static final @NotNull AndroidLazyEvaluator staticAppInfo33 = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo( + context.getPackageName(), + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA)); + } catch (Throwable e) { + return null; + } + }); + + private static final @NotNull AndroidLazyEvaluator staticAppInfo = + new AndroidLazyEvaluator<>( + context -> { + try { + return context + .getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + } catch (Throwable e) { + return null; + } + }); + /** * Return the Application's PackageInfo if possible, or null. * @@ -70,10 +172,12 @@ private ContextUtils() {} */ @Nullable static PackageInfo getPackageInfo( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider) { - return getPackageInfo(context, 0, logger, buildInfoProvider); + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { + return staticPackageInfo33.getValue(context); + } else { + return staticPackageInfo.getValue(context); + } } /** @@ -110,22 +214,14 @@ static PackageInfo getPackageInfo( * @return the Application's ApplicationInfo if possible, or throws */ @SuppressLint("NewApi") - @NotNull + @Nullable @SuppressWarnings("deprecation") static ApplicationInfo getApplicationInfo( - final @NotNull Context context, - final long flag, - final @NotNull BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { - return context - .getPackageManager() - .getApplicationInfo( - context.getPackageName(), PackageManager.ApplicationInfoFlags.of(flag)); + return staticAppInfo33.getValue(context); } else { - return context - .getPackageManager() - .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + return staticAppInfo.getValue(context); } } @@ -169,15 +265,7 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { */ @ApiStatus.Internal public static boolean isForegroundImportance() { - try { - final ActivityManager.RunningAppProcessInfo appProcessInfo = - new ActivityManager.RunningAppProcessInfo(); - ActivityManager.getMyMemoryState(appProcessInfo); - return appProcessInfo.importance == IMPORTANCE_FOREGROUND; - } catch (Throwable ignored) { - // should never happen - } - return false; + return isForegroundImportance.getValue(); } /** @@ -213,7 +301,7 @@ public static boolean isForegroundImportance() { final @NotNull BuildInfoProvider buildInfoProvider) { String packageName = null; try { - final PackageInfo packageInfo = getPackageInfo(context, logger, buildInfoProvider); + final PackageInfo packageInfo = getPackageInfo(context, buildInfoProvider); final PackageManager packageManager = context.getPackageManager(); if (packageInfo != null && packageManager != null) { @@ -239,24 +327,8 @@ public static boolean isForegroundImportance() { * * @return Application name */ - static @Nullable String getApplicationName( - final @NotNull Context context, final @NotNull ILogger logger) { - try { - final ApplicationInfo applicationInfo = context.getApplicationInfo(); - final int stringId = applicationInfo.labelRes; - if (stringId == 0) { - if (applicationInfo.nonLocalizedLabel != null) { - return applicationInfo.nonLocalizedLabel.toString(); - } - return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); - } else { - return context.getString(stringId); - } - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error getting application name.", e); - } - - return null; + static @Nullable String getApplicationName(final @NotNull Context context) { + return applicationName.getValue(context); } /** @@ -290,7 +362,7 @@ public static boolean isForegroundImportance() { } static @Nullable String getDeviceName(final @NotNull Context context) { - return Settings.Global.getString(context.getContentResolver(), "device_name"); + return deviceName.getValue(context); } @SuppressWarnings("deprecation") @@ -349,7 +421,7 @@ public static boolean isForegroundImportance() { // If this receiver is listening for broadcasts sent from the system or from other apps, even // other apps that you own—use the RECEIVER_EXPORTED flag. If instead this receiver is // listening only for broadcasts sent by your app, use the RECEIVER_NOT_EXPORTED flag. - return context.registerReceiver(receiver, filter, RECEIVER_EXPORTED); + return context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); } else { return context.registerReceiver(receiver, filter); } @@ -398,4 +470,15 @@ public static Context getApplicationContext(final @NotNull Context context) { } return context; } + + @TestOnly + static void resetInstance() { + deviceName.resetValue(); + isForegroundImportance.resetValue(); + staticPackageInfo33.resetValue(); + staticPackageInfo.resetValue(); + applicationName.resetValue(); + staticAppInfo33.resetValue(); + staticAppInfo.resetValue(); + } } 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 a2833d2b346..8edb9737e25 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 @@ -156,7 +156,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { if (user.getId() == null) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null) { + if (user.getIpAddress() == null && options.isSendDefaultPii()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } @@ -250,7 +250,7 @@ private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String } private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); final @NotNull TimeSpan appStartTimeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); if (appStartTimeSpan.hasStarted()) { 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 e2dfee2705a..aba2d1ed511 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 @@ -9,7 +9,6 @@ import android.content.IntentFilter; import android.os.BatteryManager; import android.os.Build; -import android.os.Environment; import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; @@ -156,8 +155,13 @@ public OperatingSystem getOperatingSystem() { return os; } + @Nullable + public Long getTotalMemory() { + return totalMem; + } + @NotNull - protected OperatingSystem retrieveOperatingSystemInformation() { + private OperatingSystem retrieveOperatingSystemInformation() { final OperatingSystem os = new OperatingSystem(); os.setName("Android"); @@ -384,15 +388,14 @@ private Long getUnusedInternalStorage(final @NotNull StatFs stat) { @Nullable private StatFs getExternalStorageStat(final @Nullable File internalStorage) { - if (!isExternalStorageMounted()) { + try { File path = getExternalStorageDep(internalStorage); if (path != null) { // && path.canRead()) { canRead() will read return false return new StatFs(path.getPath()); } + } catch (Throwable e) { options.getLogger().log(SentryLevel.INFO, "Not possible to read external files directory"); - return null; } - options.getLogger().log(SentryLevel.INFO, "External storage is not mounted or emulated."); return null; } @@ -444,13 +447,6 @@ private Long getTotalExternalStorage(final @NotNull StatFs stat) { } } - private boolean isExternalStorageMounted() { - final String storageState = Environment.getExternalStorageState(); - return (Environment.MEDIA_MOUNTED.equals(storageState) - || Environment.MEDIA_MOUNTED_READ_ONLY.equals(storageState)) - && !Environment.isExternalStorageEmulated(); - } - /** * Get the unused amount of external storage, in bytes, or null if no external storage is mounted. * diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java index 902f7efc2b5..7b98147aab8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/IDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; @@ -11,5 +12,8 @@ public interface IDebugImagesLoader { @Nullable List loadDebugImages(); + @Nullable + Set loadDebugImagesForAddresses(Set addresses); + void clearDebugImages(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index a3a15d7326c..2125be1671a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -108,7 +108,7 @@ public static Map serializeScope( if (app == null) { app = new App(); } - app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + app.setAppName(ContextUtils.getApplicationName(context)); final @NotNull TimeSpan appStartTimeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 23072265eb0..8f7353f4e3d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -10,7 +10,6 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,7 +18,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); - private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; @@ -80,7 +78,6 @@ private void startSession() { final @Nullable Session currentSession = scope.getSession(); if (currentSession != null && currentSession.getStarted() != null) { lastUpdatedSession.set(currentSession.getStarted().getTime()); - isFreshSession.set(true); } } }); @@ -92,11 +89,8 @@ private void startSession() { hub.startSession(); } hub.getOptions().getReplayController().start(); - } else if (!isFreshSession.get()) { - // only resume if it's not a fresh session, which has been started in SentryAndroid.init - hub.getOptions().getReplayController().resume(); } - isFreshSession.set(false); + hub.getOptions().getReplayController().resume(); this.lastUpdatedSession.set(currentTimeMillis); } 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 2d2df5700af..9a27544aeff 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 @@ -2,7 +2,6 @@ import android.content.Context; import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; import android.os.Bundle; import io.sentry.ILogger; import io.sentry.SentryIntegrationPackageStorage; @@ -495,7 +494,10 @@ private static boolean readBool( private static @NotNull Double readDouble( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float - final Double value = ((Number) metadata.getFloat(key, metadata.getInt(key, -1))).doubleValue(); + double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); + if (value == -1) { + value = ((Integer) metadata.getInt(key, -1)).doubleValue(); + } logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } @@ -538,18 +540,14 @@ static boolean isAutoInit(final @NotNull Context context, final @NotNull ILogger * * @param context the application context * @return the Bundle attached to the PackageManager - * @throws PackageManager.NameNotFoundException if the package name is non-existent */ private static @Nullable Bundle getMetadata( final @NotNull Context context, final @NotNull ILogger logger, - final @Nullable BuildInfoProvider buildInfoProvider) - throws PackageManager.NameNotFoundException { + final @Nullable BuildInfoProvider buildInfoProvider) { final ApplicationInfo app = ContextUtils.getApplicationInfo( - context, - PackageManager.GET_META_DATA, - buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); - return app.metaData; + context, buildInfoProvider != null ? buildInfoProvider : new BuildInfoProvider(logger)); + return app != null ? app.metaData : null; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java index 70451972a76..193b7342193 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NoOpDebugImagesLoader.java @@ -2,6 +2,7 @@ import io.sentry.protocol.DebugImage; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.Nullable; final class NoOpDebugImagesLoader implements IDebugImagesLoader { @@ -19,6 +20,11 @@ public static NoOpDebugImagesLoader getInstance() { return null; } + @Override + public @Nullable Set loadDebugImagesForAddresses(Set addresses) { + return null; + } + @Override public void clearDebugImages() {} } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java deleted file mode 100644 index 249904fd162..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.sentry.android.core; - -import static android.Manifest.permission.READ_PHONE_STATE; -import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; - -import android.content.Context; -import android.telephony.TelephonyManager; -import io.sentry.Breadcrumb; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.Permissions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class PhoneStateBreadcrumbsIntegration implements Integration, Closeable { - - private final @NotNull Context context; - private @Nullable SentryAndroidOptions options; - @TestOnly @Nullable PhoneStateChangeListener listener; - private @Nullable TelephonyManager telephonyManager; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); - - public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - } - - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs() - && Permissions.hasPermission(context, READ_PHONE_STATE)) { - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startTelephonyListener(hub, options); - } - } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start PhoneStateBreadcrumbsIntegration on executor thread.", - e); - } - } - } - - @SuppressWarnings("deprecation") - private void startTelephonyListener( - final @NotNull IHub hub, final @NotNull SentryOptions options) { - telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); - if (telephonyManager != null) { - try { - listener = new PhoneStateChangeListener(hub); - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE); - - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("PhoneStateBreadcrumbs"); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.INFO, e, "TelephonyManager is not available or ready to use."); - } - } else { - options.getLogger().log(SentryLevel.INFO, "TelephonyManager is not available"); - } - } - - @SuppressWarnings("deprecation") - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (telephonyManager != null && listener != null) { - telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_NONE); - listener = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration removed."); - } - } - } - - @SuppressWarnings("deprecation") - static final class PhoneStateChangeListener extends android.telephony.PhoneStateListener { - - private final @NotNull IHub hub; - - PhoneStateChangeListener(final @NotNull IHub hub) { - this.hub = hub; - } - - @SuppressWarnings("deprecation") - @Override - public void onCallStateChanged(int state, String incomingNumber) { - // incomingNumber is never used and it's always empty if you don't have permission: - // android.permission.READ_CALL_LOG - if (state == TelephonyManager.CALL_STATE_RINGING) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "CALL_STATE_RINGING"); - breadcrumb.setMessage("Device ringing"); - breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); - } - } - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 8cdc2461d23..7feecb827c9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -1,10 +1,11 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; +import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; +import android.graphics.Bitmap; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -12,6 +13,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.Debouncer; +import io.sentry.android.core.internal.util.ScreenshotUtils; import io.sentry.protocol.SentryTransaction; import io.sentry.util.HintUtils; import io.sentry.util.Objects; @@ -87,14 +89,19 @@ public ScreenshotEventProcessor( return event; } - final byte[] screenshot = - takeScreenshot( + final Bitmap screenshot = + captureScreenshot( activity, options.getMainThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } - hint.setScreenshot(Attachment.fromScreenshot(screenshot)); + hint.setScreenshot( + Attachment.fromByteProvider( + () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), + "screenshot.png", + "image/png", + false)); hint.set(ANDROID_ACTIVITY, activity); return event; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index adeb451332a..9fee04f2515 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -152,7 +152,7 @@ public static synchronized void init( } } if (context.getApplicationContext() instanceof Application) { - appStartMetrics.registerApplicationForegroundCheck( + appStartMetrics.registerLifecycleCallbacks( (Application) context.getApplicationContext()); } final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 0aa946c2553..ade1826c2c1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -3,17 +3,13 @@ import static io.sentry.Sentry.APP_START_PROFILING_CONFIG_FILE_NAME; import android.annotation.SuppressLint; -import android.app.Activity; import android.app.Application; import android.content.Context; import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Build; -import android.os.Handler; -import android.os.Looper; import android.os.Process; import android.os.SystemClock; -import androidx.annotation.NonNull; import io.sentry.ILogger; import io.sentry.ITransactionProfiler; import io.sentry.JsonSerializer; @@ -22,9 +18,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.TracesSamplingDecision; -import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; -import io.sentry.android.core.performance.ActivityLifecycleCallbacksAdapter; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import java.io.BufferedReader; @@ -33,7 +27,6 @@ import java.io.FileNotFoundException; import java.io.InputStreamReader; import java.io.Reader; -import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -185,8 +178,9 @@ private void onAppLaunched( // performance v2: Uses Process.getStartUptimeMillis() // requires API level 24+ - if (buildInfoProvider.getSdkInfoVersion() < android.os.Build.VERSION_CODES.N) { - return; + if (buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); } if (context instanceof Application) { @@ -196,40 +190,6 @@ private void onAppLaunched( return; } - final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); - appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); - appStartMetrics.registerApplicationForegroundCheck(app); - - final AtomicBoolean firstDrawDone = new AtomicBoolean(false); - - activityCallback = - new ActivityLifecycleCallbacksAdapter() { - @Override - public void onActivityStarted(@NonNull Activity activity) { - if (firstDrawDone.get()) { - return; - } - if (activity.getWindow() != null) { - FirstDrawDoneListener.registerForNextDraw( - activity, () -> onAppStartDone(), buildInfoProvider); - } else { - new Handler(Looper.getMainLooper()).post(() -> onAppStartDone()); - } - } - }; - - app.registerActivityLifecycleCallbacks(activityCallback); - } - - synchronized void onAppStartDone() { - final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - appStartMetrics.getSdkInitTimeSpan().stop(); - appStartMetrics.getAppStartTimeSpan().stop(); - - if (app != null) { - if (activityCallback != null) { - app.unregisterActivityLifecycleCallbacks(activityCallback); - } - } + appStartMetrics.registerLifecycleCallbacks(app); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index ea838975cde..ac605b84149 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -1,31 +1,17 @@ package io.sentry.android.core; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DELETED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_DISABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_ENABLED; -import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; -import static android.content.Intent.ACTION_APP_ERROR; import static android.content.Intent.ACTION_BATTERY_CHANGED; -import static android.content.Intent.ACTION_BATTERY_LOW; -import static android.content.Intent.ACTION_BATTERY_OKAY; -import static android.content.Intent.ACTION_BOOT_COMPLETED; -import static android.content.Intent.ACTION_BUG_REPORT; import static android.content.Intent.ACTION_CAMERA_BUTTON; import static android.content.Intent.ACTION_CONFIGURATION_CHANGED; import static android.content.Intent.ACTION_DATE_CHANGED; import static android.content.Intent.ACTION_DEVICE_STORAGE_LOW; import static android.content.Intent.ACTION_DEVICE_STORAGE_OK; import static android.content.Intent.ACTION_DOCK_EVENT; +import static android.content.Intent.ACTION_DREAMING_STARTED; +import static android.content.Intent.ACTION_DREAMING_STOPPED; import static android.content.Intent.ACTION_INPUT_METHOD_CHANGED; import static android.content.Intent.ACTION_LOCALE_CHANGED; -import static android.content.Intent.ACTION_MEDIA_BAD_REMOVAL; -import static android.content.Intent.ACTION_MEDIA_MOUNTED; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTABLE; -import static android.content.Intent.ACTION_MEDIA_UNMOUNTED; -import static android.content.Intent.ACTION_POWER_CONNECTED; -import static android.content.Intent.ACTION_POWER_DISCONNECTED; -import static android.content.Intent.ACTION_REBOOT; import static android.content.Intent.ACTION_SCREEN_OFF; import static android.content.Intent.ACTION_SCREEN_ON; import static android.content.Intent.ACTION_SHUTDOWN; @@ -39,6 +25,10 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IHub; @@ -46,12 +36,13 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.util.Objects; import io.sentry.util.StringUtils; import java.io.Closeable; import java.io.IOException; -import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -63,23 +54,46 @@ public final class SystemEventsBreadcrumbsIntegration implements Integration, Cl private final @NotNull Context context; - @TestOnly @Nullable SystemEventsBroadcastReceiver receiver; + @TestOnly @Nullable volatile SystemEventsBroadcastReceiver receiver; + + @TestOnly @Nullable volatile ReceiverLifecycleHandler lifecycleHandler; + + private final @NotNull MainLooperHandler handler; private @Nullable SentryAndroidOptions options; - private final @NotNull List actions; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + private @Nullable IHub hub; + + private final @NotNull String[] actions; + private volatile boolean isClosed = false; + private volatile boolean isStopped = false; + private volatile IntentFilter filter = null; + private final @NotNull Object receiverLock = new Object(); public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { - this(context, getDefaultActions()); + this(context, getDefaultActionsInternal()); + } + + private SystemEventsBreadcrumbsIntegration( + final @NotNull Context context, final @NotNull String[] actions) { + this(context, actions, new MainLooperHandler()); + } + + SystemEventsBreadcrumbsIntegration( + final @NotNull Context context, + final @NotNull String[] actions, + final @NotNull MainLooperHandler handler) { + this.context = ContextUtils.getApplicationContext(context); + this.actions = actions; + this.handler = handler; } public SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull List actions) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - this.actions = Objects.requireNonNull(actions, "Actions list is required"); + this.context = ContextUtils.getApplicationContext(context); + this.actions = new String[actions.size()]; + actions.toArray(this.actions); + this.handler = new MainLooperHandler(); } @Override @@ -89,6 +103,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); + this.hub = hub; this.options .getLogger() @@ -98,111 +113,219 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio this.options.isEnableSystemEventBreadcrumbs()); if (this.options.isEnableSystemEventBreadcrumbs()) { + addLifecycleObserver(this.options); + registerReceiver(this.hub, this.options, /* reportAsNewIntegration = */ true); + } + } - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startSystemEventsReceiver(hub, (SentryAndroidOptions) options); + private void registerReceiver( + final @NotNull IHub hub, + final @NotNull SentryAndroidOptions options, + final boolean reportAsNewIntegration) { + + if (!options.isEnableSystemEventBreadcrumbs()) { + return; + } + + synchronized (receiverLock) { + if (isClosed || isStopped || receiver != null) { + return; + } + } + + try { + options + .getExecutorService() + .submit( + () -> { + synchronized (receiverLock) { + if (isClosed || isStopped || receiver != null) { + return; + } + + receiver = new SystemEventsBroadcastReceiver(hub, options); + if (filter == null) { + filter = new IntentFilter(); + for (String item : actions) { + filter.addAction(item); } } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start SystemEventsBreadcrumbsIntegration on executor thread.", - e); - } + try { + // registerReceiver can throw SecurityException but it's not documented in the + // official docs + ContextUtils.registerReceiver(context, options, receiver, filter); + if (reportAsNewIntegration) { + options + .getLogger() + .log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion("SystemEventsBreadcrumbs"); + } + } catch (Throwable e) { + options.setEnableSystemEventBreadcrumbs(false); + options + .getLogger() + .log( + SentryLevel.ERROR, + "Failed to initialize SystemEventsBreadcrumbsIntegration.", + e); + } + } + }); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to start SystemEventsBreadcrumbsIntegration on executor thread."); } } - private void startSystemEventsReceiver( - final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { - receiver = new SystemEventsBroadcastReceiver(hub, options); - final IntentFilter filter = new IntentFilter(); - for (String item : actions) { - filter.addAction(item); + private void unregisterReceiver() { + final @Nullable SystemEventsBroadcastReceiver receiverRef; + synchronized (receiverLock) { + isStopped = true; + receiverRef = receiver; + receiver = null; + } + + if (receiverRef != null) { + context.unregisterReceiver(receiverRef); } + } + + // TODO: this duplicates a lot of AppLifecycleIntegration. We should register once on init + // and multiplex to different listeners rather. + private void addLifecycleObserver(final @NotNull SentryAndroidOptions options) { try { - // registerReceiver can throw SecurityException but it's not documented in the official docs - ContextUtils.registerReceiver(context, options, receiver, filter); - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("SystemEventsBreadcrumbs"); + Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); + Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); + if (AndroidMainThreadChecker.getInstance().isMainThread()) { + addObserverInternal(options); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + handler.post(() -> addObserverInternal(options)); + } + } catch (ClassNotFoundException e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "androidx.lifecycle is not available, SystemEventsBreadcrumbsIntegration won't be able" + + " to register/unregister an internal BroadcastReceiver. This may result in an" + + " increased ANR rate on Android 14 and above."); } catch (Throwable e) { - options.setEnableSystemEventBreadcrumbs(false); options .getLogger() - .log(SentryLevel.ERROR, "Failed to initialize SystemEventsBreadcrumbsIntegration.", e); + .log( + SentryLevel.ERROR, + "SystemEventsBreadcrumbsIntegration could not register lifecycle observer", + e); } } - @SuppressWarnings("deprecation") - private static @NotNull List getDefaultActions() { - final List actions = new ArrayList<>(); - actions.add(ACTION_APPWIDGET_DELETED); - actions.add(ACTION_APPWIDGET_DISABLED); - actions.add(ACTION_APPWIDGET_ENABLED); - actions.add("android.appwidget.action.APPWIDGET_HOST_RESTORED"); - actions.add("android.appwidget.action.APPWIDGET_RESTORED"); - actions.add(ACTION_APPWIDGET_UPDATE); - actions.add("android.appwidget.action.APPWIDGET_UPDATE_OPTIONS"); - actions.add(ACTION_POWER_CONNECTED); - actions.add(ACTION_POWER_DISCONNECTED); - actions.add(ACTION_SHUTDOWN); - actions.add(ACTION_AIRPLANE_MODE_CHANGED); - actions.add(ACTION_BATTERY_LOW); - actions.add(ACTION_BATTERY_OKAY); - actions.add(ACTION_BATTERY_CHANGED); - actions.add(ACTION_BOOT_COMPLETED); - actions.add(ACTION_CAMERA_BUTTON); - actions.add(ACTION_CONFIGURATION_CHANGED); - actions.add("android.intent.action.CONTENT_CHANGED"); - actions.add(ACTION_DATE_CHANGED); - actions.add(ACTION_DEVICE_STORAGE_LOW); - actions.add(ACTION_DEVICE_STORAGE_OK); - actions.add(ACTION_DOCK_EVENT); - actions.add("android.intent.action.DREAMING_STARTED"); - actions.add("android.intent.action.DREAMING_STOPPED"); - actions.add(ACTION_INPUT_METHOD_CHANGED); - actions.add(ACTION_LOCALE_CHANGED); - actions.add(ACTION_REBOOT); - actions.add(ACTION_SCREEN_OFF); - actions.add(ACTION_SCREEN_ON); - actions.add(ACTION_TIMEZONE_CHANGED); - actions.add(ACTION_TIME_CHANGED); - actions.add("android.os.action.DEVICE_IDLE_MODE_CHANGED"); - actions.add("android.os.action.POWER_SAVE_MODE_CHANGED"); - // The user pressed the "Report" button in the crash/ANR dialog. - actions.add(ACTION_APP_ERROR); - // Show activity for reporting a bug. - actions.add(ACTION_BUG_REPORT); - - // consider if somebody mounted or ejected a sdcard - actions.add(ACTION_MEDIA_BAD_REMOVAL); - actions.add(ACTION_MEDIA_MOUNTED); - actions.add(ACTION_MEDIA_UNMOUNTABLE); - actions.add(ACTION_MEDIA_UNMOUNTED); + private void addObserverInternal(final @NotNull SentryAndroidOptions options) { + lifecycleHandler = new ReceiverLifecycleHandler(); - return actions; + try { + ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleHandler); + } catch (Throwable e) { + // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in + // connection with conflicting dependencies of the androidx.lifecycle. + // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 + lifecycleHandler = null; + options + .getLogger() + .log( + SentryLevel.ERROR, + "SystemEventsBreadcrumbsIntegration failed to get Lifecycle and could not install lifecycle observer.", + e); + } + } + + private void removeLifecycleObserver() { + if (lifecycleHandler != null) { + if (AndroidMainThreadChecker.getInstance().isMainThread()) { + removeObserverInternal(); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + handler.post(() -> removeObserverInternal()); + } + } + } + + private void removeObserverInternal() { + final @Nullable ReceiverLifecycleHandler watcherRef = lifecycleHandler; + if (watcherRef != null) { + ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); + } + lifecycleHandler = null; } @Override public void close() throws IOException { - synchronized (startLock) { + synchronized (receiverLock) { isClosed = true; + filter = null; } - if (receiver != null) { - context.unregisterReceiver(receiver); - receiver = null; - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); + removeLifecycleObserver(); + unregisterReceiver(); + + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); + } + } + + public static @NotNull List getDefaultActions() { + return Arrays.asList(getDefaultActionsInternal()); + } + + @SuppressWarnings("deprecation") + private static @NotNull String[] getDefaultActionsInternal() { + final String[] actions = new String[19]; + actions[0] = ACTION_SHUTDOWN; + actions[1] = ACTION_AIRPLANE_MODE_CHANGED; + actions[2] = ACTION_BATTERY_CHANGED; + actions[3] = ACTION_CAMERA_BUTTON; + actions[4] = ACTION_CONFIGURATION_CHANGED; + actions[5] = ACTION_DATE_CHANGED; + actions[6] = ACTION_DEVICE_STORAGE_LOW; + actions[7] = ACTION_DEVICE_STORAGE_OK; + actions[8] = ACTION_DOCK_EVENT; + actions[9] = ACTION_DREAMING_STARTED; + actions[10] = ACTION_DREAMING_STOPPED; + actions[11] = ACTION_INPUT_METHOD_CHANGED; + actions[12] = ACTION_LOCALE_CHANGED; + actions[13] = ACTION_SCREEN_OFF; + actions[14] = ACTION_SCREEN_ON; + actions[15] = ACTION_TIMEZONE_CHANGED; + actions[16] = ACTION_TIME_CHANGED; + actions[17] = "android.os.action.DEVICE_IDLE_MODE_CHANGED"; + actions[18] = "android.os.action.POWER_SAVE_MODE_CHANGED"; + return actions; + } + + final class ReceiverLifecycleHandler implements DefaultLifecycleObserver { + @Override + public void onStart(@NonNull LifecycleOwner owner) { + if (hub == null || options == null) { + return; + } + + synchronized (receiverLock) { + isStopped = false; } + + registerReceiver(hub, options, /* reportAsNewIntegration = */ false); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + unregisterReceiver(); } } @@ -243,12 +366,45 @@ public void onReceive(final Context context, final @NotNull Intent intent) { hub.addBreadcrumb(breadcrumb, hint); }); } catch (Throwable t) { - options - .getLogger() - .log(SentryLevel.ERROR, t, "Failed to submit system event breadcrumb action."); + // ignored } } + // in theory this should be ThreadLocal, but we won't have more than 1 thread accessing it, + // so we save some memory here and CPU cycles. 64 is because all intent actions we subscribe for + // are less than 64 chars. We also don't care about encoding as those are always UTF. + // TODO: _MULTI_THREADED_EXECUTOR_ + private final char[] buf = new char[64]; + + @TestOnly + @Nullable + String getStringAfterDotFast(final @Nullable String str) { + if (str == null) { + return null; + } + + final int len = str.length(); + int bufIndex = buf.length; + + // the idea here is to iterate from the end of the string and copy the characters to a + // pre-allocated buffer in reverse order. When we find a dot, we create a new string + // from the buffer. This way we use a fixed size buffer and do a bare minimum of iterations. + for (int i = len - 1; i >= 0; i--) { + final char c = str.charAt(i); + if (c == '.') { + return new String(buf, bufIndex, buf.length - bufIndex); + } + if (bufIndex == 0) { + // Overflow — fallback to safe version + return StringUtils.getStringAfterDot(str); + } + buf[--bufIndex] = c; + } + + // No dot found — return original + return str; + } + private @NotNull Breadcrumb createBreadcrumb( final long timeMs, final @NotNull Intent intent, @@ -257,7 +413,7 @@ public void onReceive(final Context context, final @NotNull Intent intent) { final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - final String shortAction = StringUtils.getStringAfterDot(action); + final String shortAction = getStringAfterDotFast(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index b94a06b9768..8b137891791 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -1,144 +1 @@ -package io.sentry.android.core; -import static android.content.Context.SENSOR_SERVICE; -import static io.sentry.TypeCheckHint.ANDROID_SENSOR_EVENT; -import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; - -import android.content.Context; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import io.sentry.Breadcrumb; -import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryLevel; -import io.sentry.SentryOptions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; - -public final class TempSensorBreadcrumbsIntegration - implements Integration, Closeable, SensorEventListener { - - private final @NotNull Context context; - private @Nullable IHub hub; - private @Nullable SentryAndroidOptions options; - - @TestOnly @Nullable SensorManager sensorManager; - private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); - - public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = - Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); - } - - @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); - this.options = - Objects.requireNonNull( - (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, - "SentryAndroidOptions is required"); - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "enableSystemEventsBreadcrumbs enabled: %s", - this.options.isEnableSystemEventBreadcrumbs()); - - if (this.options.isEnableSystemEventBreadcrumbs()) { - - try { - options - .getExecutorService() - .submit( - () -> { - synchronized (startLock) { - if (!isClosed) { - startSensorListener(options); - } - } - }); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "Failed to start TempSensorBreadcrumbsIntegration on executor thread.", - e); - } - } - } - - private void startSensorListener(final @NotNull SentryOptions options) { - try { - sensorManager = (SensorManager) context.getSystemService(SENSOR_SERVICE); - if (sensorManager != null) { - final Sensor defaultSensor = - sensorManager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE); - if (defaultSensor != null) { - sensorManager.registerListener(this, defaultSensor, SensorManager.SENSOR_DELAY_NORMAL); - - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion("TempSensorBreadcrumbs"); - } else { - options.getLogger().log(SentryLevel.INFO, "TYPE_AMBIENT_TEMPERATURE is not available."); - } - } else { - options.getLogger().log(SentryLevel.INFO, "SENSOR_SERVICE is not available."); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, e, "Failed to init. the SENSOR_SERVICE."); - } - } - - @Override - public void close() throws IOException { - synchronized (startLock) { - isClosed = true; - } - if (sensorManager != null) { - sensorManager.unregisterListener(this); - sensorManager = null; - - if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration removed."); - } - } - } - - @Override - public void onSensorChanged(final @NotNull SensorEvent event) { - final float[] values = event.values; - // return if data is not available or zero'ed - if (values == null || values.length == 0 || values[0] == 0f) { - return; - } - - if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("system"); - breadcrumb.setCategory("device.event"); - breadcrumb.setData("action", "TYPE_AMBIENT_TEMPERATURE"); - breadcrumb.setData("accuracy", event.accuracy); - breadcrumb.setData("timestamp", event.timestamp); - breadcrumb.setLevel(SentryLevel.INFO); - breadcrumb.setData("degree", event.values[0]); // Celsius - - final Hint hint = new Hint(); - hint.set(ANDROID_SENSOR_EVENT, event); - - hub.addBreadcrumb(breadcrumb, hint); - } - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) {} -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index a0ad3591669..87e60fa6bfd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -50,6 +50,11 @@ private void startTracking(final @NotNull Activity activity) { delegate = new NoOpWindowCallback(); } + if (delegate instanceof SentryWindowCallback) { + // already instrumented + return; + } + final SentryGestureListener gestureListener = new SentryGestureListener(activity, hub, options); window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index 945ebeef649..f271c3da9e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -18,7 +18,6 @@ public final class AndroidViewGestureTargetLocator implements GestureTargetLocat private static final String ORIGIN = "old_view_system"; private final boolean isAndroidXAvailable; - private final int[] coordinates = new int[2]; public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { this.isAndroidXAvailable = isAndroidXAvailable; @@ -26,18 +25,16 @@ public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { if (!(root instanceof View)) { return null; } final View view = (View) root; - if (touchWithinBounds(view, x, y)) { - if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { - return createUiElement(view); - } else if (targetType == UiElement.Type.SCROLLABLE - && isViewScrollable(view, isAndroidXAvailable)) { - return createUiElement(view); - } + if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { + return createUiElement(view); + } else if (targetType == UiElement.Type.SCROLLABLE + && isViewScrollable(view, isAndroidXAvailable)) { + return createUiElement(view); } return null; } @@ -52,17 +49,6 @@ private UiElement createUiElement(final @NotNull View targetView) { } } - private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); - } - private static boolean isViewTappable(final @NotNull View view) { return view.isClickable() && view.getVisibility() == View.VISIBLE; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 6e7dab2ef5a..8aa8e89d694 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -7,7 +7,6 @@ import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import io.sentry.util.Objects; import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,6 +16,32 @@ @ApiStatus.Internal public final class ViewUtils { + private static final int[] coordinates = new int[2]; + + /** + * Verifies if the given touch coordinates are within the bounds of the given view. + * + * @param view the view to check if the touch coordinates are within its bounds + * @param x - the x coordinate of a {@link MotionEvent} + * @param y - the y coordinate of {@link MotionEvent} + * @return true if the touch coordinates are within the bounds of the view, false otherwise + */ + private static boolean touchWithinBounds( + final @Nullable View view, final float x, final float y) { + if (view == null) { + return false; + } + + view.getLocationOnScreen(coordinates); + int vx = coordinates[0]; + int vy = coordinates[1]; + + int w = view.getWidth(); + int h = view.getHeight(); + + return !(x < vx || x > vx + w || y < vy || y > vy + h); + } + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. @@ -40,7 +65,12 @@ public final class ViewUtils { @Nullable UiElement target = null; while (queue.size() > 0) { - final View view = Objects.requireNonNull(queue.poll(), "view is required"); + final View view = queue.poll(); + + if (!touchWithinBounds(view, x, y)) { + // if the touch is not hitting the view, skip traversal of its children + continue; + } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; @@ -54,7 +84,7 @@ public final class ViewUtils { if (newTarget != null) { if (targetType == UiElement.Type.CLICKABLE) { target = newTarget; - } else { + } else if (targetType == UiElement.Type.SCROLLABLE) { return newTarget; } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java index b6374a32e36..05bb75a60f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java @@ -21,6 +21,10 @@ public final class AssetsModulesLoader extends ModulesLoader { public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { super(logger); this.context = ContextUtils.getApplicationContext(context); + + // pre-load modules on a bg thread to avoid doing so on the main thread in case of a crash/error + //noinspection Convert2MethodRef + new Thread(() -> getOrLoadModules()).start(); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 45e9d56877d..25d38b64130 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -27,6 +27,10 @@ public class ScreenshotUtils { private static final long CAPTURE_TIMEOUT_MS = 1000; + // Used by Hybrid SDKs + /** + * @noinspection unused + */ public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, @@ -35,12 +39,33 @@ public class ScreenshotUtils { activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider); } + // Used by Hybrid SDKs @SuppressLint("NewApi") public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull IMainThreadChecker mainThreadChecker, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { + + final @Nullable Bitmap screenshot = + captureScreenshot(activity, mainThreadChecker, logger, buildInfoProvider); + return compressBitmapToPng(screenshot, logger); + } + + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + return captureScreenshot( + activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider); + } + + @SuppressLint("NewApi") + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { // We are keeping BuildInfoProvider param for compatibility, as it's being used by // cross-platform SDKs @@ -72,7 +97,7 @@ public class ScreenshotUtils { return null; } - try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + try { // ARGB_8888 -> This configuration is very flexible and offers the best quality final Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); @@ -133,10 +158,31 @@ public class ScreenshotUtils { return null; } } + return bitmap; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + } + return null; + } + /** + * Compresses the supplied Bitmap to a PNG byte array. After compression, the Bitmap will be + * recycled. + * + * @param bitmap The bitmap to compress + * @param logger the logger + * @return the Bitmap in PNG format, or null if the bitmap was null, recycled or compressing faile + */ + public static @Nullable byte[] compressBitmapToPng( + final @Nullable Bitmap bitmap, final @NotNull ILogger logger) { + if (bitmap == null || bitmap.isRecycled()) { + return null; + } + try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { // 0 meaning compress for small size, 100 meaning compress for max quality. // Some formats, like PNG which is lossless, will ignore the quality setting. bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream); + bitmap.recycle(); if (byteArrayOutputStream.size() <= 0) { logger.log(SentryLevel.DEBUG, "Screenshot is 0 bytes, not attaching the image."); @@ -146,7 +192,7 @@ public class ScreenshotUtils { // screenshot png is around ~100-150 kb return byteArrayOutputStream.toByteArray(); } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e); } return null; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 2e249d6dccf..6107c2e1178 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -11,17 +11,20 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.sentry.ITransactionProfiler; -import io.sentry.SentryDate; -import io.sentry.SentryNanotimeDate; +import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -30,6 +33,9 @@ * An in-memory representation for app-metrics during app start. As the SDK can't be initialized * that early, we can't use transactions or spans directly. Thus simple TimeSpans are used and later * transformed into SDK specific txn/span data structures. + * + *

This class is also responsible for - determining the app start type (cold, warm) - determining + * if the app was launched in foreground */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { @@ -45,7 +51,7 @@ public enum AppStartType { private static volatile @Nullable AppStartMetrics instance; private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground = false; + private boolean appLaunchedInForeground; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -54,10 +60,10 @@ public enum AppStartType { private final @NotNull List activityLifecycles; private @Nullable ITransactionProfiler appStartProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; - private @Nullable SentryDate onCreateTime = null; - private boolean appLaunchTooLong = false; private boolean isCallbackRegistered = false; private boolean shouldSendStartMeasurements = true; + private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); + private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); public static @NotNull AppStartMetrics getInstance() { @@ -116,10 +122,6 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } - public boolean isColdStartValid() { - return appLaunchedInForeground && !appLaunchTooLong; - } - @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; @@ -153,17 +155,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements; - } - - public void restartAppStart(final long uptimeMillis) { - shouldSendStartMeasurements = true; - appLaunchTooLong = false; - appLaunchedInForeground = true; - appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(uptimeMillis); - CLASS_LOADED_UPTIME_MS = appStartSpan.getStartUptimeMs(); + return shouldSendStartMeasurements && appLaunchedInForeground; } public long getClassLoadedUptimeMs() { @@ -176,20 +168,27 @@ public long getClassLoadedUptimeMs() { */ public @NotNull TimeSpan getAppStartTimeSpanWithFallback( final @NotNull SentryAndroidOptions options) { - // If the app launch took too long or it was launched in the background we return an empty span - if (!isColdStartValid()) { - return new TimeSpan(); - } - if (options.isEnablePerformanceV2()) { - // Only started when sdk version is >= N - final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); - if (appStartSpan.hasStarted()) { - return appStartSpan; + // If the app start type was never determined or app wasn't launched in foreground, + // the app start is considered invalid + if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted() + && appStartSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + final @NotNull TimeSpan sdkInitTimeSpan = getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasStarted() + && sdkInitTimeSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return sdkInitTimeSpan; } } - // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return new TimeSpan(); } @TestOnly @@ -205,11 +204,11 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; - appLaunchTooLong = false; appLaunchedInForeground = false; - onCreateTime = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; + firstDrawDone.set(false); + activeActivitiesCounter.set(0); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -247,7 +246,23 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.registerApplicationForegroundCheck(application); + instance.registerLifecycleCallbacks(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); } } @@ -256,7 +271,7 @@ public static void onApplicationCreate(final @NotNull Application application) { * * @param application The application object to register the callback to */ - public void registerApplicationForegroundCheck(final @NotNull Application application) { + public void registerLifecycleCallbacks(final @NotNull Application application) { if (isCallbackRegistered) { return; } @@ -267,15 +282,15 @@ public void registerApplicationForegroundCheck(final @NotNull Application applic // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); } - private void checkCreateTimeOnMain(final @NotNull Application application) { + private void checkCreateTimeOnMain() { new Handler(Looper.getMainLooper()) .post( () -> { // if no activity has ever been created, app was launched in background - if (onCreateTime == null) { + if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground = false; // we stop the app start profiler, as it's useless and likely to timeout @@ -284,43 +299,56 @@ private void checkCreateTimeOnMain(final @NotNull Application application) { appStartProfiler = null; } } - application.unregisterActivityLifecycleCallbacks(instance); }); } @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - // An activity already called onCreate() - if (!appLaunchedInForeground || onCreateTime != null) { + final long nowUptimeMs = SystemClock.uptimeMillis(); + + // the first activity determines the app start type + if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { + // If the app (process) was launched more than 1 minute ago, it's likely wrong + final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); + if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + appStartType = AppStartType.WARM; + + shouldSendStartMeasurements = true; + appStartSpan.reset(); + appStartSpan.start(); + appStartSpan.setStartedAt(nowUptimeMs); + CLASS_LOADED_UPTIME_MS = nowUptimeMs; + contentProviderOnCreates.clear(); + applicationOnCreate.reset(); + } else { + appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + } + } + appLaunchedInForeground = true; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { return; } - onCreateTime = new SentryNanotimeDate(); - - final long spanStartMillis = appStartSpan.getStartTimestampMs(); - final long spanEndMillis = - appStartSpan.hasStopped() - ? appStartSpan.getProjectedStopTimestampMs() - : System.currentTimeMillis(); - final long durationMillis = spanEndMillis - spanStartMillis; - // If the app was launched more than 1 minute ago, it's likely wrong - if (durationMillis > TimeUnit.MINUTES.toMillis(1)) { - appLaunchTooLong = true; + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(), new BuildInfoProvider(NoOpLogger.getInstance())); + } else { + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn()); } } - /** - * Called by instrumentation - * - * @param application The application object where onCreate was called on - * @noinspection unused - */ - public static void onApplicationPostCreate(final @NotNull Application application) { - final long now = SystemClock.uptimeMillis(); - - final @NotNull AppStartMetrics instance = getInstance(); - if (instance.applicationOnCreate.hasNotStopped()) { - instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); - instance.applicationOnCreate.setStoppedAt(now); + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + // if the app is moving into background + // as the next Activity is considered like a new app start + if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appLaunchedInForeground = false; + shouldSendStartMeasurements = true; + firstDrawDone.set(false); } } @@ -354,4 +382,12 @@ public static void onContentProviderPostCreate(final @NotNull ContentProvider co measurement.setStoppedAt(now); } } + + synchronized void onFirstFrameDrawn() { + if (!firstDrawDone.getAndSet(true)) { + final @NotNull AppStartMetrics appStartMetrics = getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java new file mode 100644 index 00000000000..beb9ff8e8ed --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/util/AndroidLazyEvaluator.java @@ -0,0 +1,68 @@ +package io.sentry.android.core.util; + +import android.content.Context; +import io.sentry.util.LazyEvaluator; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. Same as {@link LazyEvaluator} but accepts Context as an + * argument for {@link AndroidLazyEvaluator#getValue}. + */ +@ApiStatus.Internal +public final class AndroidLazyEvaluator { + + private volatile @Nullable T value = null; + private final @NotNull AndroidEvaluator evaluator; + + /** + * Class that evaluates a function lazily. It means the evaluator function is called only when + * getValue is called, and it's cached. + * + * @param evaluator The function to evaluate. + */ + public AndroidLazyEvaluator(final @NotNull AndroidEvaluator evaluator) { + this.evaluator = evaluator; + } + + /** + * Executes the evaluator function and caches its result, so that it's called only once, unless + * resetValue is called. + * + * @return The result of the evaluator function. + */ + public @Nullable T getValue(final @NotNull Context context) { + if (value == null) { + synchronized (this) { + if (value == null) { + value = evaluator.evaluate(context); + } + } + } + + return value; + } + + public void setValue(final @Nullable T value) { + synchronized (this) { + this.value = value; + } + } + + /** + * Resets the internal value and forces the evaluator function to be called the next time + * getValue() is called. + */ + public void resetValue() { + synchronized (this) { + this.value = null; + } + } + + public interface AndroidEvaluator { + @Nullable + T evaluate(@NotNull Context context); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index be000b7517c..c14d6c82c4a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -54,7 +54,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import java.util.Date import java.util.concurrent.Future -import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -94,7 +93,10 @@ class ActivityLifecycleIntegrationTest { whenever(hub.options).thenReturn(options) - AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.start() + // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() @@ -132,6 +134,7 @@ class ActivityLifecycleIntegrationTest { @BeforeTest fun `reset instance`() { AppStartMetrics.getInstance().clear() + ContextUtils.resetInstance() context = ApplicationProvider.getApplicationContext() val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? @@ -593,45 +596,6 @@ class ActivityLifecycleIntegrationTest { verify(ttfdReporter, never()).registerFullyDrawnListener(any()) } - @Test - fun `App start is Cold when savedInstanceState is null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `App start is Warm when savedInstanceState is not null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `Do not overwrite App start type after set`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - @Test fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() @@ -882,86 +846,6 @@ class ActivityLifecycleIntegrationTest { ) } - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start warm span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, fixture.bundle) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start cold span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.cold") - assertEquals(span.description, "Cold Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started more than 1 minute ago, start app with Warm start`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started in background, start app with Warm start`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - @Test fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() @@ -1466,7 +1350,6 @@ class ActivityLifecycleIntegrationTest { assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) sut.onActivityCreated(activity, null) - assertNotNull(sut.appStartSpan) sut.onActivityPostCreated(activity, null) assertTrue(activityLifecycleSpan.onCreate.hasStopped()) @@ -1555,15 +1438,6 @@ class ActivityLifecycleIntegrationTest { // lastPausedUptimeMillis is set to current SystemClock.uptimeMillis() val lastUptimeMillis = sut.getProperty("lastPausedUptimeMillis") assertNotEquals(0, lastUptimeMillis) - - sut.onActivityCreated(activity, null) - // AppStartMetrics app start time is set to Activity preCreated timestamp - assertEquals(lastUptimeMillis, appStartMetrics.appStartTimeSpan.startUptimeMs) - // AppStart type is considered warm - assertEquals(AppStartType.WARM, appStartMetrics.appStartType) - - // Activity appStart span timestamp is the same of AppStartMetrics.appStart timestamp - assertEquals(sut.appStartSpan!!.startDate.nanoTimestamp(), appStartMetrics.getAppStartTimeSpanWithFallback(fixture.options).startTimestamp!!.nanoTimestamp()) } private fun SentryTracer.isFinishing() = getProperty("finishStatus").getProperty("isFinishing") @@ -1577,6 +1451,9 @@ class ActivityLifecycleIntegrationTest { private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) { // set by SentryPerformanceProvider so forcing it here + AppStartMetrics.getInstance().appStartType = AppStartType.COLD + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index ed2fa3338a5..31d1547ca49 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -20,6 +20,7 @@ import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -55,6 +56,7 @@ class AndroidOptionsInitializerTest { configureContext: Context.() -> Unit = {}, assets: AssetManager? = null ) { + sentryOptions.executorService = ImmediateExecutorService() mockContext = if (metadata != null) { ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext), @@ -164,6 +166,7 @@ class AndroidOptionsInitializerTest { @BeforeTest fun `set up`() { + ContextUtils.resetInstance() val appContext = ApplicationProvider.getApplicationContext() fixture = Fixture(appContext, appContext.cacheDir) } @@ -685,9 +688,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `PersistingScopeObserver is not set to options, if scope persistence is disabled`() { + fun `PersistingScopeObserver is no-op, if scope persistence is disabled`() { fixture.initSut(configureOptions = { isEnableScopePersistence = false }) - assertTrue { fixture.sentryOptions.scopeObservers.none { it is PersistingScopeObserver } } + fixture.sentryOptions.findPersistingScopeObserver()?.setTags(mapOf("key" to "value")) + assertFalse(File(AndroidOptionsInitializer.getCacheDir(fixture.context), PersistingScopeObserver.SCOPE_CACHE).exists()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index 80ae9467114..6d2d005eaab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -35,6 +35,7 @@ import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.PersistingScopeObserver.USER_FILENAME +import io.sentry.cache.tape.QueueFile import io.sentry.hints.AbnormalExit import io.sentry.hints.Backfillable import io.sentry.protocol.Browser @@ -61,6 +62,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowBuild +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -85,7 +87,6 @@ class AnrV2EventProcessorTest { lateinit var context: Context val options = SentryAndroidOptions().apply { setLogger(NoOpLogger.getInstance()) - isSendDefaultPii = true } fun getSut( @@ -93,10 +94,14 @@ class AnrV2EventProcessorTest { currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, - replayErrorSampleRate: Double? = null + replayErrorSampleRate: Double? = null, + isSendDefaultPii: Boolean = true ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" + options.isSendDefaultPii = isSendDefaultPii + options.addScopeObserver(PersistingScopeObserver(options)) + whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -145,7 +150,16 @@ class AnrV2EventProcessorTest { fun persistScope(filename: String, entity: T) { val dir = File(options.cacheDirPath, SCOPE_CACHE).also { it.mkdirs() } val file = File(dir, filename) - options.serializer.serialize(entity, file.writer()) + if (filename == BREADCRUMBS_FILENAME) { + val queueFile = QueueFile.Builder(file).build() + (entity as List).forEach { crumb -> + val baos = ByteArrayOutputStream() + options.serializer.serialize(crumb, baos.writer()) + queueFile.add(baos.toByteArray()) + } + } else { + options.serializer.serialize(entity, file.writer()) + } } fun persistOptions(filename: String, entity: T) { @@ -170,6 +184,7 @@ class AnrV2EventProcessorTest { @BeforeTest fun `set up`() { + DeviceInfoUtil.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } @@ -278,6 +293,7 @@ class AnrV2EventProcessorTest { // user assertEquals("bot", processed.user!!.username) assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) // trace assertEquals("ui.load", processed.contexts.trace!!.operation) // tags @@ -304,6 +320,13 @@ class AnrV2EventProcessorTest { assertEquals("Google Chrome", processed.contexts.browser!!.name) } + @Test + fun `when backfillable event is enrichable, does not backfill user ip`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + assertNull(processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -610,13 +633,14 @@ class AnrV2EventProcessorTest { val processed = processor.process(SentryEvent(), hint)!! assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) - assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + assertEquals(replayId1.toString(), fixture.options.findPersistingScopeObserver()?.read(fixture.options, REPLAY_FILENAME, String::class.java)) } private fun processEvent( hint: Hint, populateScopeCache: Boolean = false, populateOptionsCache: Boolean = false, + isSendDefaultPii: Boolean = true, configureEvent: SentryEvent.() -> Unit = {} ): SentryEvent { val original = SentryEvent().apply(configureEvent) @@ -624,7 +648,8 @@ class AnrV2EventProcessorTest { val processor = fixture.getSut( tmpDir, populateScopeCache = populateScopeCache, - populateOptionsCache = populateOptionsCache + populateOptionsCache = populateOptionsCache, + isSendDefaultPii = isSendDefaultPii ) return processor.process(original, hint)!! } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt index 8f45c238029..bbc16f3691c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import java.lang.NullPointerException import kotlin.test.Test @@ -95,24 +96,6 @@ class AppComponentsBreadcrumbsIntegrationTest { sut.close() } - @Test - fun `When low memory event, a breadcrumb with type, category and level should be set`() { - val sut = fixture.getSut() - val options = SentryAndroidOptions().apply { - executorService = ImmediateExecutorService() - } - val hub = mock() - sut.register(hub, options) - sut.onLowMemory() - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.WARNING, it.level) - } - ) - } - @Test fun `When trim memory event with level, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() @@ -127,7 +110,8 @@ class AppComponentsBreadcrumbsIntegrationTest { assertEquals("device.event", it.category) assertEquals("system", it.type) assertEquals(SentryLevel.WARNING, it.level) - } + }, + anyOrNull() ) } @@ -162,4 +146,26 @@ class AppComponentsBreadcrumbsIntegrationTest { anyOrNull() ) } + + @Test + fun `low memory changes are debounced`() { + val sut = fixture.getSut() + + val hub = mock() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + sut.register(hub, options) + sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) + sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + + // should only add the first crumb + verify(hub).addBreadcrumb( + check { + assertEquals(it.data["level"], 40) + }, + anyOrNull() + ) + verifyNoMoreInteractions(hub) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index 588a32a6569..dc224a5bee6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -42,6 +42,7 @@ class ContextUtilsTest { @BeforeTest fun `set up`() { + ContextUtils.resetInstance() context = ApplicationProvider.getApplicationContext() logger = NoOpLogger.getInstance() ShadowBuild.reset() @@ -51,20 +52,20 @@ class ContextUtilsTest { @Test fun `Given a valid context, returns a valid PackageInfo`() { - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(context, mock()) assertNotNull(packageInfo) } @Test fun `Given an invalid context, do not throw Error`() { // as Context is not fully mocked, it'll throw NPE but catch it and return null - val packageInfo = ContextUtils.getPackageInfo(mock(), mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(mock(), mock()) assertNull(packageInfo) } @Test fun `Given a valid PackageInfo, returns a valid versionCode`() { - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock()) + val packageInfo = ContextUtils.getPackageInfo(context, mock()) val versionCode = ContextUtils.getVersionCode(packageInfo!!, mock()) assertNotNull(versionCode) @@ -73,7 +74,7 @@ class ContextUtilsTest { @Test fun `Given a valid PackageInfo, returns a valid versionName`() { // VersionName is null during tests, so we mock it the second time - val packageInfo = ContextUtils.getPackageInfo(context, mock(), mock())!! + val packageInfo = ContextUtils.getPackageInfo(context, mock())!! val versionName = ContextUtils.getVersionName(packageInfo) assertNull(versionName) val mockedPackageInfo = spy(packageInfo) { it.versionName = "" } @@ -83,13 +84,13 @@ class ContextUtilsTest { @Test fun `when context is valid, getApplicationName returns application name`() { - val appName = ContextUtils.getApplicationName(context, logger) + val appName = ContextUtils.getApplicationName(context) assertEquals("io.sentry.android.core.test", appName) } @Test fun `when context is invalid, getApplicationName returns null`() { - val appName = ContextUtils.getApplicationName(mock(), logger) + val appName = ContextUtils.getApplicationName(mock()) assertNull(appName) } @@ -193,7 +194,7 @@ class ContextUtilsTest { val context = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.TIRAMISU) ContextUtils.registerReceiver(context, buildInfo, receiver, filter) - verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_EXPORTED)) + verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_NOT_EXPORTED)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 80954f67a5f..0dfcf663bff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -66,7 +66,8 @@ class DefaultAndroidEventProcessorTest { lateinit var sentryTracer: SentryTracer - fun getSut(context: Context): DefaultAndroidEventProcessor { + fun getSut(context: Context, isSendDefaultPii: Boolean = false): DefaultAndroidEventProcessor { + options.isSendDefaultPii = isSendDefaultPii whenever(hub.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("", ""), hub) return DefaultAndroidEventProcessor(context, buildInfo, options) @@ -284,8 +285,20 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `when event user data does not have ip address set, sets {{auto}} as the ip address`() { - val sut = fixture.getSut(context) + fun `when event user data does not have ip address set, sets no ip address if sendDefaultPii is false`() { + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { + user = User() + } + sut.process(event, Hint()) + assertNotNull(event.user) { + assertNull(it.ipAddress) + } + } + + @Test + fun `when event user data does not have ip address set, sets {{auto}} if sendDefaultPii is true`() { + val sut = fixture.getSut(context, isSendDefaultPii = true) val event = SentryEvent().apply { user = User() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 1bc88961da4..4f4f46e63fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -254,7 +254,7 @@ class LifecycleWatcherTest { } @Test - fun `if the hub has already a fresh session running, doesn't resume replay`() { + fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { val watcher = fixture.getSUT( enableAppLifecycleBreadcrumbs = false, session = Session( @@ -276,7 +276,7 @@ class LifecycleWatcherTest { ) watcher.onStart(fixture.ownerMock) - verify(fixture.replayController, never()).resume() + verify(fixture.replayController).resume() } @Test @@ -293,7 +293,7 @@ class LifecycleWatcherTest { verify(fixture.replayController).pause() watcher.onStart(fixture.ownerMock) - verify(fixture.replayController).resume() + verify(fixture.replayController, times(2)).resume() watcher.onStop(fixture.ownerMock) verify(fixture.replayController, timeout(10000)).stop() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d60f47bd2cc..e1e5b3b9aa6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -37,6 +38,11 @@ class ManifestMetadataReaderTest { private val fixture = Fixture() + @BeforeTest + fun `set up`() { + ContextUtils.resetInstance() + } + @Test fun `isAutoInit won't throw exception and is enabled by default`() { fixture.options.setDebug(true) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 35e0f5257bf..c0d18e5db75 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -75,7 +75,7 @@ class PerformanceAndroidEventProcessorTest { null, null ).also { - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + AppStartMetrics.getInstance().onActivityCreated(mock(), if (coldStart) null else mock()) } @BeforeTest @@ -225,6 +225,7 @@ class PerformanceAndroidEventProcessorTest { fun `adds app start metrics to app start txn`() { // given some app start metrics val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.isAppLaunchedInForeground = true appStartMetrics.appStartType = AppStartType.COLD appStartMetrics.appStartTimeSpan.setStartedAt(123) appStartMetrics.appStartTimeSpan.setStoppedAt(456) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt deleted file mode 100644 index 2b6ca801dae..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package io.sentry.android.core - -import android.content.Context -import android.telephony.PhoneStateListener -import android.telephony.TelephonyManager -import io.sentry.Breadcrumb -import io.sentry.IHub -import io.sentry.ISentryExecutorService -import io.sentry.SentryLevel -import io.sentry.test.DeferredExecutorService -import io.sentry.test.ImmediateExecutorService -import org.mockito.kotlin.any -import org.mockito.kotlin.check -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class PhoneStateBreadcrumbsIntegrationTest { - - private class Fixture { - val context = mock() - val manager = mock() - val options = SentryAndroidOptions() - - fun getSut(executorService: ISentryExecutorService = ImmediateExecutorService()): PhoneStateBreadcrumbsIntegration { - options.executorService = executorService - whenever(context.getSystemService(eq(Context.TELEPHONY_SERVICE))).thenReturn(manager) - - return PhoneStateBreadcrumbsIntegration(context) - } - } - - private val fixture = Fixture() - - @Test - fun `When system events breadcrumb is enabled, it registers callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_CALL_STATE)) - assertNotNull(sut.listener) - } - - @Test - fun `Phone state callback is registered in the executorService`() { - val sut = fixture.getSut(mock()) - val hub = mock() - sut.register(hub, fixture.options) - - assertNull(sut.listener) - } - - @Test - fun `When system events breadcrumb is disabled, it doesn't register callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register( - hub, - fixture.options.apply { - isEnableSystemEventBreadcrumbs = false - } - ) - verify(fixture.manager, never()).listen(any(), any()) - assertNull(sut.listener) - } - - @Test - fun `When ActivityBreadcrumbsIntegration is closed, it should unregister the callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.close() - verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_NONE)) - assertNull(sut.listener) - } - - @Test - fun `when hub is closed right after start, integration is not registered`() { - val deferredExecutorService = DeferredExecutorService() - val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(mock(), fixture.options) - assertNull(sut.listener) - sut.close() - deferredExecutorService.runAll() - assertNull(sut.listener) - } - - @Test - fun `When on call state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_RINGING, null) - - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When on idle state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_IDLE, null) - verify(hub, never()).addBreadcrumb(any()) - } - - @Test - fun `When on offhook state received, added breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK, null) - verify(hub, never()).addBreadcrumb(any()) - } -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index aa266d5c7a2..7bcc35b6b56 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -182,6 +182,8 @@ class SentryAndroidOptionsTest { private class CustomDebugImagesLoader : IDebugImagesLoader { override fun loadDebugImages(): List? = null + override fun loadDebugImagesForAddresses(addresses: Set?): Set? = null + override fun clearDebugImages() {} } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index ec2b3db4ce3..9645bb0b2d5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -11,6 +11,7 @@ import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger import io.sentry.ISentryClient @@ -36,10 +37,13 @@ import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.cache.tape.QueueFile +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils import org.awaitility.kotlin.await @@ -61,6 +65,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Files import java.util.concurrent.TimeUnit @@ -413,27 +418,31 @@ class SentryAndroidTest { assertEquals("Debug!", event.breadcrumbs!![0].message) assertEquals("staging", event.environment) assertEquals("io.sentry.sample@2.0.0", event.release) + assertEquals("afcb46b1140ade5187c4bbb5daa804df", event.contexts[Contexts.REPLAY_ID]) asserted.set(true) null } // have to do it after the cacheDir is set to options, because it adds a dsn hash after prefillOptionsCache(it.cacheDirPath!!) - prefillScopeCache(it.cacheDirPath!!) + prefillScopeCache(it, it.cacheDirPath!!) it.release = "io.sentry.sample@1.1.0+220" it.environment = "debug" - // this is necessary to delay the AnrV2Integration processing to execute the configure - // scope block below (otherwise it won't be possible as hub is no-op before .init) - it.executorService.submit { - Sentry.configureScope { scope -> - // make sure the scope values changed to test that we're still using previously - // persisted values for the old ANR events - assertEquals("TestActivity", scope.transactionName) - } - } options = it } + options.executorService.submit { + // verify we reset the persisted scope values after the init bg tasks have run to ensure + // clean state for a new process. + assertEquals( + emptyList(), + options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java) + ) + assertEquals( + SentryId.EMPTY_ID.toString(), + options.findPersistingScopeObserver()?.read(options, REPLAY_FILENAME, String::class.java) + ) + } Sentry.configureScope { it.setTransaction("TestActivity") it.addBreadcrumb(Breadcrumb.error("Error!")) @@ -447,7 +456,7 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) + options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java) ) assertEquals( "io.sentry.sample@1.1.0+220", @@ -461,7 +470,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(21, options.integrations.size) + assertEquals(19, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -479,8 +488,6 @@ class SentryAndroidTest { it is AppComponentsBreadcrumbsIntegration || it is SystemEventsBreadcrumbsIntegration || it is NetworkBreadcrumbsIntegration || - it is TempSensorBreadcrumbsIntegration || - it is PhoneStateBreadcrumbsIntegration || it is SpotlightIntegration || it is ReplayIntegration } @@ -530,19 +537,22 @@ class SentryAndroidTest { assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) } - private fun prefillScopeCache(cacheDir: String) { + private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } - File(scopeDir, BREADCRUMBS_FILENAME).writeText( - """ - [{ - "timestamp": "2009-11-16T01:08:47.000Z", - "message": "Debug!", - "type": "debug", - "level": "debug" - }] - """.trimIndent() + val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build() + val baos = ByteArrayOutputStream() + options.serializer.serialize( + Breadcrumb(DateUtils.getDateTime("2009-11-16T01:08:47.000Z")).apply { + message = "Debug!" + type = "debug" + level = DEBUG + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(scopeDir, TRANSACTION_FILENAME).writeText("\"MainActivity\"") + File(scopeDir, REPLAY_FILENAME).writeText("\"afcb46b1140ade5187c4bbb5daa804df\"") + File(options.getCacheDirPath(), "replay_afcb46b1140ade5187c4bbb5daa804df").mkdirs() } private fun prefillOptionsCache(cacheDir: String) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index 5b546523d01..927e8792376 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -20,6 +20,7 @@ class SentryInitProviderTest { @BeforeTest fun `set up`() { Sentry.close() + ContextUtils.resetInstance() } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 1939e7ed801..f6d6d229852 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -42,6 +42,7 @@ class SentryLogcatAdapterTest { fun `set up`() { Sentry.close() AppStartMetrics.getInstance().clear() + ContextUtils.resetInstance() breadcrumbs.clear() fixture.initSut { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 9f868d701b4..5e816f6ca50 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.content.pm.ProviderInfo import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,7 +17,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -48,6 +48,7 @@ class SentryPerformanceProviderTest { val providerInfo = ProviderInfo() val logger = mock() lateinit var configFile: File + var activityLifecycleCallbacks: MutableList = mutableListOf() fun getSut(sdkVersion: Int = Build.VERSION_CODES.S, authority: String = AUTHORITY, handleFile: ((config: File) -> Unit)? = null): SentryPerformanceProvider { val buildInfoProvider: BuildInfoProvider = mock() @@ -56,7 +57,14 @@ class SentryPerformanceProviderTest { whenever(mockContext.applicationContext).thenReturn(mockContext) configFile = File(sentryCache, Sentry.APP_START_PROFILING_CONFIG_FILE_NAME) handleFile?.invoke(configFile) - + whenever(mockContext.registerActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.add(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } + whenever(mockContext.unregisterActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.remove(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } providerInfo.authority = authority return SentryPerformanceProvider(logger, buildInfoProvider).apply { attachInfo(mockContext, providerInfo) @@ -104,24 +112,11 @@ class SentryPerformanceProviderTest { @Test fun `provider sets both appstart and sdk init start + end times`() { val provider = fixture.getSut() - provider.onAppStartDone() + provider.onCreate() val metrics = AppStartMetrics.getInstance() assertTrue(metrics.appStartTimeSpan.hasStarted()) - assertTrue(metrics.appStartTimeSpan.hasStopped()) - assertTrue(metrics.sdkInitTimeSpan.hasStarted()) - assertTrue(metrics.sdkInitTimeSpan.hasStopped()) - } - - @Test - fun `provider properly registers and unregisters ActivityLifecycleCallbacks`() { - val provider = fixture.getSut() - - // It register once for the provider itself and once for the appStartMetrics - verify(fixture.mockContext, times(2)).registerActivityLifecycleCallbacks(any()) - provider.onAppStartDone() - verify(fixture.mockContext).unregisterActivityLifecycleCallbacks(any()) } //region app start profiling diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index e6d3dfadd7e..d47ca2076cf 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.lifecycle.Lifecycle.Event.ON_START import androidx.lifecycle.Lifecycle.Event.ON_STOP import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CheckIn @@ -24,7 +25,6 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.transport.RateLimiter import org.junit.runner.RunWith -import org.mockito.kotlin.mock import org.robolectric.annotation.Config import java.util.LinkedList import kotlin.test.BeforeTest @@ -116,7 +116,7 @@ class SessionTrackingIntegrationTest { } private fun setupLifecycle(options: SentryOptions): LifecycleRegistry { - val lifecycle = LifecycleRegistry(mock()) + val lifecycle = LifecycleRegistry(ProcessLifecycleOwner.get()) val lifecycleWatcher = ( options.integrations.find { it is AppLifecycleIntegration diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index 3dfca15fdb3..956f3dc1cc1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -3,6 +3,8 @@ package io.sentry.android.core import android.content.Context import android.content.Intent import android.os.BatteryManager +import android.os.Build +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IHub @@ -16,9 +18,13 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -26,19 +32,30 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) class SystemEventsBreadcrumbsIntegrationTest { private class Fixture { val context = mock() var options = SentryAndroidOptions() val hub = mock() + lateinit var handler: MainLooperHandler - fun getSut(enableSystemEventBreadcrumbs: Boolean = true, executorService: ISentryExecutorService = ImmediateExecutorService()): SystemEventsBreadcrumbsIntegration { + fun getSut( + enableSystemEventBreadcrumbs: Boolean = true, + executorService: ISentryExecutorService = ImmediateExecutorService(), + mockHandler: Boolean = true + ): SystemEventsBreadcrumbsIntegration { + handler = if (mockHandler) mock() else MainLooperHandler() options = SentryAndroidOptions().apply { isEnableSystemEventBreadcrumbs = enableSystemEventBreadcrumbs this.executorService = executorService } - return SystemEventsBreadcrumbsIntegration(context) + return SystemEventsBreadcrumbsIntegration( + context, + SystemEventsBreadcrumbsIntegration.getDefaultActions().toTypedArray(), + handler + ) } } @@ -50,7 +67,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) - verify(fixture.context).registerReceiver(any(), any()) + verify(fixture.context).registerReceiver(any(), any(), any()) assertNotNull(sut.receiver) } @@ -69,7 +86,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) - verify(fixture.context, never()).registerReceiver(any(), any()) + verify(fixture.context, never()).registerReceiver(any(), any(), any()) assertNull(sut.receiver) } @@ -102,6 +119,8 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) val intent = Intent().apply { action = Intent.ACTION_TIME_CHANGED + putExtra("test", 10) + putExtra("test2", 20) } sut.receiver!!.onReceive(fixture.context, intent) @@ -174,10 +193,227 @@ class SystemEventsBreadcrumbsIntegrationTest { @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() - whenever(fixture.context.registerReceiver(any(), any())).thenThrow(SecurityException()) + whenever(fixture.context.registerReceiver(any(), any(), any())).thenThrow(SecurityException()) sut.register(fixture.hub, fixture.options) assertFalse(fixture.options.isEnableSystemEventBreadcrumbs) } + + @Test + fun `when str has full package, return last string after dot`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertEquals("DEVICE_IDLE_MODE_CHANGED", sut.receiver?.getStringAfterDotFast("io.sentry.DEVICE_IDLE_MODE_CHANGED")) + assertEquals("POWER_SAVE_MODE_CHANGED", sut.receiver?.getStringAfterDotFast("io.sentry.POWER_SAVE_MODE_CHANGED")) + } + + @Test + fun `when str is null, return null`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertNull(sut.receiver?.getStringAfterDotFast(null)) + } + + @Test + fun `when str is empty, return the original str`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertEquals("", sut.receiver?.getStringAfterDotFast("")) + } + + @Test + fun `when str ends with a dot, return empty str`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertEquals("", sut.receiver?.getStringAfterDotFast("io.sentry.")) + } + + @Test + fun `when str has no dots, return the original str`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertEquals("iosentry", sut.receiver?.getStringAfterDotFast("iosentry")) + } + + @Test + fun `When integration is added, lifecycle handler should be started`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + } + + @Test + fun `When system events breadcrumbs are disabled, lifecycle handler should not be started`() { + val sut = fixture.getSut() + fixture.options.apply { + isEnableSystemEventBreadcrumbs = false + } + + sut.register(fixture.hub, fixture.options) + + assertNull(sut.lifecycleHandler) + } + + @Test + fun `When integration is closed, lifecycle handler should be closed`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + + sut.close() + + assertNull(sut.lifecycleHandler) + } + + @Test + fun `When integration is registered from a background thread, post on the main thread`() { + val sut = fixture.getSut() + val latch = CountDownLatch(1) + + Thread { + sut.register(fixture.hub, fixture.options) + latch.countDown() + }.start() + + latch.await() + + verify(fixture.handler).post(any()) + } + + @Test + fun `When integration is closed from a background thread, post on the main thread`() { + val sut = fixture.getSut() + val latch = CountDownLatch(1) + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + + Thread { + sut.close() + latch.countDown() + }.start() + + latch.await() + + verify(fixture.handler).post(any()) + } + + @Test + fun `When integration is closed from a background thread, watcher is set to null`() { + val sut = fixture.getSut(mockHandler = false) + val latch = CountDownLatch(1) + + sut.register(fixture.hub, fixture.options) + + assertNotNull(sut.lifecycleHandler) + + Thread { + sut.close() + latch.countDown() + }.start() + + latch.await() + + // ensure all messages on main looper got processed + shadowOf(Looper.getMainLooper()).idle() + + assertNull(sut.lifecycleHandler) + } + + @Test + fun `when enters background unregisters receiver`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + + sut.lifecycleHandler!!.onStop(mock()) + + verify(fixture.context).unregisterReceiver(any()) + assertNull(sut.receiver) + } + + @Test + fun `when enters foreground registers receiver`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + verify(fixture.context).registerReceiver(any(), any(), any()) + + sut.lifecycleHandler!!.onStop(mock()) + sut.lifecycleHandler!!.onStart(mock()) + + verify(fixture.context, times(2)).registerReceiver(any(), any(), any()) + assertNotNull(sut.receiver) + } + + @Test + fun `when enters foreground after register does not recreate the receiver`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + verify(fixture.context).registerReceiver(any(), any(), any()) + val receiver = sut.receiver + + sut.lifecycleHandler!!.onStart(mock()) + assertEquals(receiver, sut.receiver) + } + + @Test + fun `when goes background right after entering foreground, receiver is not registered`() { + val deferredExecutorService = DeferredExecutorService() + val sut = fixture.getSut(executorService = deferredExecutorService) + sut.register(fixture.hub, fixture.options) + deferredExecutorService.runAll() + assertNotNull(sut.receiver) + + sut.lifecycleHandler!!.onStop(mock()) + sut.lifecycleHandler!!.onStart(mock()) + assertNull(sut.receiver) + sut.lifecycleHandler!!.onStop(mock()) + deferredExecutorService.runAll() + assertNull(sut.receiver) + } + + @Test + fun `when enters foreground right after closing, receiver is not registered`() { + val deferredExecutorService = DeferredExecutorService() + val latch = CountDownLatch(1) + + val sut = fixture.getSut(executorService = deferredExecutorService, mockHandler = false) + sut.register(fixture.hub, fixture.options) + deferredExecutorService.runAll() + assertNotNull(sut.receiver) + + Thread { + sut.close() + latch.countDown() + }.start() + + latch.await() + + sut.lifecycleHandler!!.onStart(mock()) + assertNull(sut.receiver) + deferredExecutorService.runAll() + + shadowOf(Looper.getMainLooper()).idle() + + assertNull(sut.receiver) + assertNull(sut.lifecycleHandler) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt deleted file mode 100644 index d443b1e3458..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package io.sentry.android.core - -import android.content.Context -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import io.sentry.Breadcrumb -import io.sentry.Hint -import io.sentry.IHub -import io.sentry.ISentryExecutorService -import io.sentry.SentryLevel -import io.sentry.TypeCheckHint -import io.sentry.test.DeferredExecutorService -import io.sentry.test.ImmediateExecutorService -import io.sentry.test.getDeclaredCtor -import io.sentry.test.injectForField -import org.mockito.kotlin.any -import org.mockito.kotlin.check -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class TempSensorBreadcrumbsIntegrationTest { - private class Fixture { - val context = mock() - val manager = mock() - val sensor = mock() - val options = SentryAndroidOptions() - - fun getSut(executorService: ISentryExecutorService = ImmediateExecutorService()): TempSensorBreadcrumbsIntegration { - options.executorService = executorService - whenever(context.getSystemService(Context.SENSOR_SERVICE)).thenReturn(manager) - whenever(manager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE)).thenReturn(sensor) - return TempSensorBreadcrumbsIntegration(context) - } - } - - private val fixture = Fixture() - - @Test - fun `When system events breadcrumb is enabled, it registers callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - verify(fixture.manager).registerListener(any(), any(), eq(SensorManager.SENSOR_DELAY_NORMAL)) - assertNotNull(sut.sensorManager) - } - - @Test - fun `temp sensor listener is registered in the executorService`() { - val sut = fixture.getSut(executorService = mock()) - val hub = mock() - sut.register(hub, fixture.options) - - assertNull(sut.sensorManager) - } - - @Test - fun `When system events breadcrumb is disabled, it should not register a callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register( - hub, - fixture.options.apply { - isEnableSystemEventBreadcrumbs = false - } - ) - verify(fixture.manager, never()).registerListener(any(), any(), any()) - assertNull(sut.sensorManager) - } - - @Test - fun `When TempSensorBreadcrumbsIntegration is closed, it should unregister the callback`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - sut.close() - verify(fixture.manager).unregisterListener(any()) - assertNull(sut.sensorManager) - } - - @Test - fun `when hub is closed right after start, integration is not registered`() { - val deferredExecutorService = DeferredExecutorService() - val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(mock(), fixture.options) - assertNull(sut.sensorManager) - sut.close() - deferredExecutorService.runAll() - assertNull(sut.sensorManager) - } - - @Test - fun `When onSensorChanged received, add a breadcrumb with type and category`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - val sensorCtor = "android.hardware.SensorEvent".getDeclaredCtor(emptyArray()) - val sensorEvent: SensorEvent = sensorCtor.newInstance() as SensorEvent - sensorEvent.injectForField("values", FloatArray(2) { 1F }) - sut.onSensorChanged(sensorEvent) - - verify(hub).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.INFO, it.level) - }, - check { - assertEquals(sensorEvent, it.get(TypeCheckHint.ANDROID_SENSOR_EVENT)) - } - ) - } - - @Test - fun `When onSensorChanged received and null values, do not add a breadcrumb`() { - val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) - val event = mock() - assertNull(event.values) - sut.onSensorChanged(event) - - verify(hub, never()).addBreadcrumb(any()) - } -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index d43dfe14197..f126e6c9229 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -140,6 +140,7 @@ class UserInteractionIntegrationTest { ) ) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(fixture.window).callback = null @@ -160,6 +161,7 @@ class UserInteractionIntegrationTest { ) ) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(fixture.window).callback = delegate @@ -170,8 +172,30 @@ class UserInteractionIntegrationTest { val callback = mock() val sut = fixture.getSut(callback) + sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(callback).stopTracking() } + + @Test + fun `does not instrument if the callback is already ours`() { + val delegate = mock() + val context = mock() + val resources = Fixture.mockResources() + whenever(context.resources).thenReturn(resources) + val existingCallback = SentryWindowCallback( + delegate, + context, + mock(), + mock() + ) + val sut = fixture.getSut(existingCallback) + + sut.register(fixture.hub, fixture.options) + sut.onActivityResumed(fixture.activity) + + val argumentCaptor = argumentCaptor() + verify(fixture.window, never()).callback = argumentCaptor.capture() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index 1e6652276a7..c864e3d4b57 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -185,7 +185,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -214,7 +214,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -223,7 +223,7 @@ class SentryGestureListenerClickTest { val event = mock() val sut = fixture.getSut(event, attachViewsToRoot = false) - fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + fixture.window.mockDecorView(event = event, touchWithinBounds = true) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(fixture.target) } @@ -244,7 +244,7 @@ class SentryGestureListenerClickTest { val event = mock() val sut = fixture.getSut(event, attachViewsToRoot = false) - fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + fixture.window.mockDecorView(event = event, touchWithinBounds = true) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(fixture.target) } @@ -253,4 +253,18 @@ class SentryGestureListenerClickTest { verify(fixture.scope).propagationContext = any() } + + @Test + fun `if touch is not within view group bounds does not traverse its children`() { + val event = mock() + val sut = fixture.getSut(event, attachViewsToRoot = false) + fixture.window.mockDecorView(event = event, touchWithinBounds = false) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(fixture.target) + } + + sut.onSingleTapUp(event) + + verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt index 18eecf3128d..10369063f6a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt @@ -1,12 +1,14 @@ package io.sentry.android.core.internal.util import android.app.Activity +import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.view.View import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger +import io.sentry.NoOpLogger import io.sentry.android.core.BuildInfoProvider import junit.framework.TestCase.assertNull import org.junit.runner.RunWith @@ -16,7 +18,9 @@ import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue @Config( shadows = [ShadowPixelCopy::class], @@ -32,7 +36,7 @@ class ScreenshotUtilTest { whenever(activity.isDestroyed).thenReturn(false) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -44,7 +48,7 @@ class ScreenshotUtilTest { whenever(activity.window).thenReturn(mock()) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -60,7 +64,7 @@ class ScreenshotUtilTest { whenever(window.peekDecorView()).thenReturn(decorView) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -81,7 +85,7 @@ class ScreenshotUtilTest { whenever(rootView.height).thenReturn(0) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -94,7 +98,7 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } @@ -107,9 +111,40 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } + + @Test + fun `a null bitmap compresses into null`() { + val bytes = ScreenshotUtils.compressBitmapToPng(null, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a recycled bitmap compresses into null`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.recycle() + + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a valid bitmap compresses into a valid bytearray`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNotNull(bytes) + assertTrue(bytes.isNotEmpty()) + } + + @Test + fun `compressBitmapToPng recycles the supplied bitmap`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + assertFalse(bitmap.isRecycled) + ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertTrue(bitmap.isRecycled) + } } class ExampleActivity : Activity() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index d8b9e727e20..855973d9418 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -1,9 +1,12 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.Application import android.content.ContentProvider import android.os.Build +import android.os.Bundle import android.os.Looper +import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ITransactionProfiler import io.sentry.android.core.SentryAndroidOptions @@ -75,6 +78,7 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled and app start time span is started, appStartTimeSpanWithFallback returns it`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.WARM appStartTimeSpan.start() val options = SentryAndroidOptions().apply { @@ -88,7 +92,12 @@ class AppStartMetricsTest { @Test fun `if perf-2 is disabled but app start time span has started, appStartTimeSpanWithFallback returns the sdk init span instead`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } + appStartTimeSpan.setStartedAt(123) val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false @@ -101,8 +110,11 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled but app start time span has not started, appStartTimeSpanWithFallback returns the sdk init span instead`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - assertTrue(appStartTimeSpan.hasNotStarted()) + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = true @@ -121,6 +133,8 @@ class AppStartMetricsTest { @Test fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { AppStartMetrics.getInstance().isAppLaunchedInForeground = false + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) @@ -136,19 +150,65 @@ class AppStartMetricsTest { } @Test - fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() - assertTrue(appStartTimeSpan.hasStarted()) - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - - val options = SentryAndroidOptions().apply { - isEnablePerformanceV2 = true + fun `if app is launched in background, but an activity launches later, a new warm start is reported with correct timings`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + metrics.contentProviderOnCreateTimeSpans.add( + TimeSpan().apply { + description = "ExampleContentProvider" + setStartedAt(1) + setStoppedAt(2) + } + ) + + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(3) + setStoppedAt(4) } - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) - assertFalse(timeSpan.hasStarted()) + // when the looper runs + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // but no activity creation happened + // then the app wasn't launched in foreground and nothing should be sent + assertFalse(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements()) + + val now = TimeUnit.MINUTES.toMillis(2) + 1234567 + SystemClock.setCurrentTimeMillis(now) + + // once an activity launches + AppStartMetrics.getInstance().onActivityCreated(mock(), null) + + // then it should restart the timespan + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + assertTrue(metrics.contentProviderOnCreateTimeSpans.isEmpty()) + } + + @Test + fun `if app is launched in background, the first created activity assumes a warm start`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + metrics.registerLifecycleCallbacks(mock()) + + // when the handler callback is executed and no activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // isAppLaunchedInForeground should be false + assertFalse(metrics.isAppLaunchedInForeground) + + // but when the first activity launches + metrics.onActivityCreated(mock(), null) + + // then a warm start should be set + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test @@ -172,14 +232,15 @@ class AppStartMetricsTest { @Test fun `if activity is never started, returns an empty span`() { - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) + val timeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) assertFalse(timeSpan.hasStarted()) } @@ -189,7 +250,7 @@ class AppStartMetricsTest { whenever(profiler.isRunning).thenReturn(true) AppStartMetrics.getInstance().appStartProfiler = profiler - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -203,7 +264,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().appStartProfiler = profiler AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -231,33 +292,26 @@ class AppStartMetricsTest { @Test fun `when multiple registerApplicationForegroundCheck, only one callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application, times(1)).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + verify( + application, + times(1) + ).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } @Test fun `when registerApplicationForegroundCheck, a callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } - @Test - fun `when registerApplicationForegroundCheck, a job is posted on main thread to unregistered the callback`() { - val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - verify(application, never()).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(application).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - } - @Test fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // Main thread performs the check and sets the flag to false if no activity was created Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -268,7 +322,7 @@ class AppStartMetricsTest { fun `registerApplicationForegroundCheck keeps foreground state to true if an activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) @@ -277,12 +331,6 @@ class AppStartMetricsTest { assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } - @Test - fun `isColdStartValid is false if app was launched in background`() { - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - assertFalse(AppStartMetrics.getInstance().isColdStartValid) - } - @Test fun `isColdStartValid is false if app launched in more than 1 minute`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -291,7 +339,6 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - assertFalse(AppStartMetrics.getInstance().isColdStartValid) } @Test @@ -307,19 +354,104 @@ class AppStartMetricsTest { } @Test - fun `restartAppStart set measurement flag and clear internal lists`() { + fun `a warm start gets reported after a cold start`() { val appStartMetrics = AppStartMetrics.getInstance() + + // when the first activity launches and gets destroyed + val activity0 = mock() + whenever(activity0.isChangingConfigurations).thenReturn(false) + appStartMetrics.onActivityCreated(activity0, null) + + // then the app start type should be cold and measurements should be sent + assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + + // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - appStartMetrics.isAppLaunchedInForeground = false assertFalse(appStartMetrics.shouldSendStartMeasurements()) - assertFalse(appStartMetrics.isColdStartValid) - appStartMetrics.restartAppStart(10) + appStartMetrics.onActivityDestroyed(activity0) + // then it should reset sending the measurements for the next warm activity + appStartMetrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) assertTrue(appStartMetrics.shouldSendStartMeasurements()) - assertTrue(appStartMetrics.isColdStartValid) - assertTrue(appStartMetrics.appStartTimeSpan.hasStarted()) - assertTrue(appStartMetrics.appStartTimeSpan.hasNotStopped()) - assertEquals(10, appStartMetrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + + assertFalse(metrics.appStartTimeSpan.hasStopped()) + assertFalse(metrics.sdkInitTimeSpan.hasStopped()) + + metrics.onFirstFrameDrawn() + + assertTrue(metrics.appStartTimeSpan.hasStopped()) + assertTrue(metrics.sdkInitTimeSpan.hasStopped()) + } + + @Test + fun `Sets app launch type to cold`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), mock()) + + // then the app start is still considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm if process init was too long ago`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + val app = mock() + metrics.registerLifecycleCallbacks(app) + + // when an activity is created later with a null bundle + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), mock()) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), null) + + // then the app start is still considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } } diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index e8f838ce8b4..cbd2e308bd6 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -10,6 +10,7 @@ public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/c public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/ndk/NativeModuleListLoader;)V public fun clearDebugImages ()V public fun loadDebugImages ()Ljava/util/List; + public fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; } public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObserverAdapter { diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java index cb38db498a6..a8574157775 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java @@ -7,7 +7,9 @@ import io.sentry.protocol.DebugImage; import io.sentry.util.Objects; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; @@ -22,7 +24,7 @@ public final class DebugImagesLoader implements IDebugImagesLoader { private final @NotNull NativeModuleListLoader moduleListLoader; - private static @Nullable List debugImages; + private static volatile @Nullable List debugImages; /** we need to lock it because it could be called from different threads */ private static final @NotNull Object debugImagesLock = new Object(); @@ -60,7 +62,92 @@ public DebugImagesLoader( return debugImages; } - /** Clears the caching of debug images on sentry-native and here. */ + /** + * Loads debug images for the given set of addresses. + * + * @param addresses Set of memory addresses to find debug images for + * @return Set of matching debug images, or null if debug images couldn't be loaded + */ + public @Nullable Set loadDebugImagesForAddresses( + final @NotNull Set addresses) { + synchronized (debugImagesLock) { + final @Nullable List allDebugImages = loadDebugImages(); + if (allDebugImages == null) { + return null; + } + if (addresses.isEmpty()) { + return null; + } + + final Set referencedImages = filterImagesByAddresses(allDebugImages, addresses); + if (referencedImages.isEmpty()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "No debug images found for any of the %d addresses.", + addresses.size()); + return null; + } + + return referencedImages; + } + } + + /** + * Finds all debug image containing the given addresses. Assumes that the images are sorted by + * address, which should always be true on Linux/Android and Windows platforms + * + * @return All matching debug images or null if none are found + */ + private @NotNull Set filterImagesByAddresses( + final @NotNull List images, final @NotNull Set addresses) { + final Set result = new HashSet<>(); + + for (int i = 0; i < images.size(); i++) { + final @NotNull DebugImage image = images.get(i); + final @Nullable DebugImage nextDebugImage = + (i + 1) < images.size() ? images.get(i + 1) : null; + final @Nullable String nextDebugImageAddress = + nextDebugImage != null ? nextDebugImage.getImageAddr() : null; + + for (final @NotNull String rawAddress : addresses) { + try { + final long address = Long.parseLong(rawAddress.replace("0x", ""), 16); + + final @Nullable String imageAddress = image.getImageAddr(); + if (imageAddress != null) { + try { + final long imageStart = Long.parseLong(imageAddress.replace("0x", ""), 16); + final long imageEnd; + + final @Nullable Long imageSize = image.getImageSize(); + if (imageSize != null) { + imageEnd = imageStart + imageSize; + } else if (nextDebugImageAddress != null) { + imageEnd = Long.parseLong(nextDebugImageAddress.replace("0x", ""), 16); + } else { + imageEnd = Long.MAX_VALUE; + } + if (address >= imageStart && address < imageEnd) { + result.add(image); + // once image is added we can skip the remaining addresses and go straight to the + // next + // image + break; + } + } catch (NumberFormatException e) { + // ignored, invalid debug image address + } + } + } catch (NumberFormatException e) { + // ignored, invalid address supplied + } + } + } + return result; + } + @Override public void clearDebugImages() { synchronized (debugImagesLock) { diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt index db584c814f6..4d14caa695d 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt @@ -5,8 +5,8 @@ import io.sentry.protocol.DebugImage import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.lang.RuntimeException import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -17,11 +17,13 @@ class DebugImagesLoaderTest { val options = SentryAndroidOptions() fun getSut(): DebugImagesLoader { - return DebugImagesLoader(options, nativeLoader) + val loader = DebugImagesLoader(options, nativeLoader) + loader.clearDebugImages() + return loader } } - private val fixture = Fixture() + private var fixture = Fixture() @Test fun `get images returns image list`() { @@ -78,4 +80,107 @@ class DebugImagesLoaderTest { assertNull(sut.cachedDebugImages) } + + @Test + fun `find images by address`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + val image3 = DebugImage().apply { + imageAddr = "0x3000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2, image3)) + + val result = sut.loadDebugImagesForAddresses( + setOf("0x1500", "0x2500") + ) + + assertNotNull(result) + assertEquals(2, result.size) + assertTrue(result.any { it.imageAddr == image1.imageAddr }) + assertTrue(result.any { it.imageAddr == image2.imageAddr }) + } + + @Test + fun `find images with invalid addresses are not added to the result`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2)) + + val hexAddresses = setOf("0xINVALID", "0x1500") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertEquals(1, result!!.size) + } + + @Test + fun `find images by address returns null if result is empty`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0x1000" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2)) + + val hexAddresses = setOf("0x100", "0x10500") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertNull(result) + } + + @Test + fun `invalid image addresses are ignored for loadDebugImagesForAddresses`() { + val sut = fixture.getSut() + + val image1 = DebugImage().apply { + imageAddr = "0xNotANumber" + imageSize = 0x1000L + } + + val image2 = DebugImage().apply { + imageAddr = "0x2000" + imageSize = null + } + + val image3 = DebugImage().apply { + imageAddr = "0x5000" + imageSize = 0x1000L + } + + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(image1, image2, image3)) + + val hexAddresses = setOf("0x100", "0x2000", "0x2000", "0x5000") + val result = sut.loadDebugImagesForAddresses(hexAddresses) + + assertNotNull(result) + assertEquals(2, result.size) + } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 33043e69b66..b238c004940 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -34,9 +34,11 @@ public final class io/sentry/android/replay/ModifierExtensionsKt { } public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + public abstract fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public abstract fun pause ()V + public abstract fun reset ()V public abstract fun resume ()V - public abstract fun start (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public abstract fun start ()V public abstract fun stop ()V } @@ -57,24 +59,24 @@ public final class io/sentry/android/replay/ReplayCache$Companion { public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; } -public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConnectionStatusProvider$IConnectionStatusObserver, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/WindowCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, io/sentry/transport/RateLimiter$IRateLimitObserver, java/io/Closeable { public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V - public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isRecording ()Z - public fun onConfigurationChanged (Landroid/content/res/Configuration;)V + public final fun onConfigurationChanged (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public fun onConnectionStatusChanged (Lio/sentry/IConnectionStatusProvider$ConnectionStatus;)V - public fun onLowMemory ()V public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public fun onScreenshotRecorded (Ljava/io/File;J)V public fun onTouchEvent (Landroid/view/MotionEvent;)V + public fun onWindowSizeChanged (II)V public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V @@ -112,7 +114,7 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig { } public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { - public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public final fun fromSize (Landroid/content/Context;Lio/sentry/SentryReplayOptions;II)Lio/sentry/android/replay/ScreenshotRecorderConfig; } public final class io/sentry/android/replay/SentryReplayModifiers { @@ -133,6 +135,10 @@ public final class io/sentry/android/replay/ViewExtensionsKt { public static final fun sentryReplayUnmask (Landroid/view/View;)V } +public abstract interface class io/sentry/android/replay/WindowCallback { + public abstract fun onWindowSizeChanged (II)V +} + public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { public static final field $stable I public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt index 6cf86b6a7e6..15cdf5b03b2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt @@ -8,11 +8,15 @@ interface Recorder : Closeable { * at which the screenshots should be taken, and the screenshots size/resolution, which can * change e.g. in the case of orientation change or window size change */ - fun start(recorderConfig: ScreenshotRecorderConfig) + fun start() + + fun onConfigurationChanged(config: ScreenshotRecorderConfig) fun resume() fun pause() + fun reset() + fun stop() } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 88638e7e168..11d3b84897e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -53,7 +53,7 @@ public class ReplayCache( internal val frames = mutableListOf() private val ongoingSegment = LinkedHashMap() - private val ongoingSegmentFile: File? by lazy { + internal val ongoingSegmentFile: File? by lazy { if (replayCacheDir == null) { return@lazy null } @@ -273,6 +273,9 @@ public class ReplayCache( if (isClosed.get()) { return } + if (ongoingSegmentFile?.exists() != true) { + ongoingSegmentFile?.createNewFile() + } if (ongoingSegment.isEmpty()) { ongoingSegmentFile?.useLines { lines -> lines.associateTo(ongoingSegment) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index c0b77abc2a5..a23ab2b9e0d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -1,8 +1,6 @@ package io.sentry.android.replay -import android.content.ComponentCallbacks import android.content.Context -import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import android.view.MotionEvent @@ -21,6 +19,11 @@ import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayState.CLOSED +import io.sentry.android.replay.ReplayState.PAUSED +import io.sentry.android.replay.ReplayState.RESUMED +import io.sentry.android.replay.ReplayState.STARTED +import io.sentry.android.replay.ReplayState.STOPPED import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment @@ -32,7 +35,6 @@ import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable @@ -55,23 +57,21 @@ public class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider, private val recorderProvider: (() -> Recorder)? = null, - private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, - ComponentCallbacks, IConnectionStatusObserver, - IRateLimitObserver { + IRateLimitObserver, + WindowCallback { // needed for the Java's call site constructor(context: Context, dateProvider: ICurrentDateProvider) : this( context.appContext(), dateProvider, null, - null, null ) @@ -79,12 +79,11 @@ public class ReplayIntegration( context: Context, dateProvider: ICurrentDateProvider, recorderProvider: (() -> Recorder)?, - recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)?, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null - ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + ) : this(context.appContext(), dateProvider, recorderProvider, replayCacheProvider) { this.replayCaptureStrategyProvider = replayCaptureStrategyProvider this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() this.gestureRecorderProvider = gestureRecorderProvider @@ -100,15 +99,15 @@ public class ReplayIntegration( Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) - private val isRecording = AtomicBoolean(false) + internal val isManualPause = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null + private val lifecycle = ReplayLifecycle() override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -126,23 +125,12 @@ public class ReplayIntegration( } this.hub = hub - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler, replayExecutor) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler, replayExecutor) gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) options.connectionStatusProvider.addConnectionStatusObserver(this) hub.rateLimiter?.addRateLimitObserver(this) - if (options.sessionReplay.isTrackOrientationChange) { - try { - context.registerComponentCallbacks(this) - } catch (e: Throwable) { - options.logger.log( - INFO, - "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", - e - ) - } - } addIntegrationToSdkVersion("Replay") SentryIntegrationPackageStorage.getInstance() @@ -151,15 +139,15 @@ public class ReplayIntegration( finalizePreviousReplay() } - override fun isRecording() = isRecording.get() + override fun isRecording() = lifecycle.currentState >= STARTED && lifecycle.currentState < STOPPED + @Synchronized override fun start() { - // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { return } - if (isRecording.getAndSet(true)) { + if (!lifecycle.isAllowed(STARTED)) { options.logger.log( DEBUG, "Session replay is already being recorded, not starting a new one" @@ -173,29 +161,44 @@ public class ReplayIntegration( return } - val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) + lifecycle.currentState = STARTED captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { SessionCaptureStrategy(options, hub, dateProvider, replayExecutor, replayCacheProvider) } else { BufferCaptureStrategy(options, hub, dateProvider, random, replayExecutor, replayCacheProvider) } + recorder?.start() + captureStrategy?.start() - captureStrategy?.start(recorderConfig) - recorder?.start(recorderConfig) registerRootViewListeners() } override fun resume() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(false) + resumeInternal() + } + + @Synchronized + private fun resumeInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(RESUMED)) { + return + } + + if (isManualPause.get() || options.connectionStatusProvider.connectionStatus == DISCONNECTED || + hub?.rateLimiter?.isActiveForCategory(All) == true || + hub?.rateLimiter?.isActiveForCategory(Replay) == true + ) { return } + lifecycle.currentState = RESUMED captureStrategy?.resume() recorder?.resume() } + @Synchronized override fun captureReplay(isTerminating: Boolean?) { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !isRecording()) { return } @@ -220,25 +223,34 @@ public class ReplayIntegration( override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter override fun pause() { - if (!isEnabled.get() || !isRecording.get()) { + isManualPause.set(true) + pauseInternal() + } + + @Synchronized + private fun pauseInternal() { + if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { return } recorder?.pause() captureStrategy?.pause() + lifecycle.currentState = PAUSED } + @Synchronized override fun stop() { - if (!isEnabled.get() || !isRecording.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(STOPPED)) { return } unregisterRootViewListeners() + recorder?.reset() recorder?.stop() gestureRecorder?.stop() captureStrategy?.stop() - isRecording.set(false) captureStrategy = null + lifecycle.currentState = STOPPED } override fun onScreenshotRecorded(bitmap: Bitmap) { @@ -257,38 +269,21 @@ public class ReplayIntegration( } } + @Synchronized override fun close() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !lifecycle.isAllowed(CLOSED)) { return } options.connectionStatusProvider.removeConnectionStatusObserver(this) hub?.rateLimiter?.removeRateLimitObserver(this) - if (options.sessionReplay.isTrackOrientationChange) { - try { - context.unregisterComponentCallbacks(this) - } catch (ignored: Throwable) { - } - } + stop() recorder?.close() recorder = null rootViewsSpy.close() replayExecutor.gracefullyShutdown(options) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get() || !isRecording.get()) { - return - } - - recorder?.stop() - - // refresh config based on new device configuration - val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay) - captureStrategy?.onConfigurationChanged(recorderConfig) - - recorder?.start(recorderConfig) + lifecycle.currentState = CLOSED } override fun onConnectionStatusChanged(status: ConnectionStatus) { @@ -298,10 +293,10 @@ public class ReplayIntegration( } if (status == DISCONNECTED) { - pause() + pauseInternal() } else { // being positive for other states, even if it's NO_PERMISSION - resume() + resumeInternal() } } @@ -312,15 +307,16 @@ public class ReplayIntegration( } if (rateLimiter.isActiveForCategory(All) || rateLimiter.isActiveForCategory(Replay)) { - pause() + pauseInternal() } else { - resume() + resumeInternal() } } - override fun onLowMemory() = Unit - override fun onTouchEvent(event: MotionEvent) { + if (!isEnabled.get() || !lifecycle.isTouchRecordingAllowed()) { + return + } captureStrategy?.onTouchEvent(event) } @@ -336,7 +332,7 @@ public class ReplayIntegration( hub?.rateLimiter?.isActiveForCategory(Replay) == true ) ) { - pause() + pauseInternal() } } @@ -374,7 +370,8 @@ public class ReplayIntegration( // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { - val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + val persistingScopeObserver = options.findPersistingScopeObserver() + val previousReplayIdString = persistingScopeObserver?.read(options, REPLAY_FILENAME, String::class.java) ?: run { cleanupReplays() return@submitSafely } @@ -387,7 +384,9 @@ public class ReplayIntegration( cleanupReplays() return@submitSafely } - val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + + @Suppress("UNCHECKED_CAST") + val breadcrumbs = persistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java) as? List val segment = CaptureStrategy.createSegment( hub = hub, options = options, @@ -414,6 +413,30 @@ public class ReplayIntegration( } } + override fun onWindowSizeChanged(width: Int, height: Int) { + if (!isEnabled.get() || !isRecording()) { + return + } + if (options.sessionReplay.isTrackConfiguration) { + val recorderConfig = + ScreenshotRecorderConfig.fromSize(context, options.sessionReplay, width, height) + onConfigurationChanged(recorderConfig) + } + } + + public fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.onConfigurationChanged(config) + recorder?.onConfigurationChanged(config) + + // we have to restart recorder with a new config and pause immediately if the replay is paused + if (lifecycle.currentState == PAUSED) { + recorder?.pause() + } + } + private class PreviousReplayHint : Backfillable { override fun shouldEnrich(): Boolean = false } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt new file mode 100644 index 00000000000..fba95fcb415 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayLifecycle.kt @@ -0,0 +1,58 @@ +package io.sentry.android.replay + +internal enum class ReplayState { + /** + * Initial state of a Replay session. This is the state when ReplayIntegration is constructed + * but has not been started yet. + */ + INITIAL, + + /** + * Started state for a Replay session. This state is reached after the start() method is called + * and the recording is initialized successfully. + */ + STARTED, + + /** + * Resumed state for a Replay session. This state is reached after resume() is called on an + * already started recording. + */ + RESUMED, + + /** + * Paused state for a Replay session. This state is reached after pause() is called on a + * resumed recording. + */ + PAUSED, + + /** + * Stopped state for a Replay session. This state is reached after stop() is called. + * The recording can be started again from this state. + */ + STOPPED, + + /** + * Closed state for a Replay session. This is the terminal state reached after close() is called. + * No further state transitions are possible after this. + */ + CLOSED; +} + +/** + * Class to manage state transitions for ReplayIntegration + */ +internal class ReplayLifecycle { + @field:Volatile + internal var currentState = ReplayState.INITIAL + + fun isAllowed(newState: ReplayState): Boolean = when (currentState) { + ReplayState.INITIAL -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.STARTED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.RESUMED -> newState == ReplayState.PAUSED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.PAUSED -> newState == ReplayState.RESUMED || newState == ReplayState.STOPPED || newState == ReplayState.CLOSED + ReplayState.STOPPED -> newState == ReplayState.STARTED || newState == ReplayState.CLOSED + ReplayState.CLOSED -> false + } + + fun isTouchRecordingAllowed(): Boolean = currentState == ReplayState.STARTED || currentState == ReplayState.RESUMED +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ba3cfc71155..7b0c275f747 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -7,15 +7,11 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix import android.graphics.Paint -import android.graphics.Point import android.graphics.Rect import android.graphics.RectF -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import android.view.PixelCopy import android.view.View import android.view.ViewTreeObserver -import android.view.WindowManager import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryLevel.WARNING @@ -53,13 +49,13 @@ internal class ScreenshotRecorder( Bitmap.createBitmap( 1, 1, - Bitmap.Config.RGB_565 + Bitmap.Config.ARGB_8888 ) } private val screenshot = Bitmap.createBitmap( config.recordingWidth, config.recordingHeight, - Bitmap.Config.RGB_565 + Bitmap.Config.ARGB_8888 ) private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } private val prescaledMatrix by lazy(NONE) { @@ -178,6 +174,9 @@ internal class ScreenshotRecorder( } override fun onDraw() { + if (!isCapturing.get()) { + return + } val root = rootView?.get() if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") @@ -217,7 +216,9 @@ internal class ScreenshotRecorder( fun close() { unbind(rootView?.get()) rootView?.clear() - screenshot.recycle() + if (!screenshot.isRecycled) { + screenshot.recycle() + } isCapturing.set(false) } @@ -288,35 +289,26 @@ public data class ScreenshotRecorderConfig( } } - fun from( + fun fromSize( context: Context, - sessionReplay: SentryReplayOptions + sessionReplay: SentryReplayOptions, + windowWidth: Int, + windowHeight: Int ): ScreenshotRecorderConfig { - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { - wm.currentWindowMetrics.bounds - } else { - val screenBounds = Point() - @Suppress("DEPRECATION") - wm.defaultDisplay.getRealSize(screenBounds) - Rect(0, 0, screenBounds.x, screenBounds.y) - } - // use the baseline density of 1x (mdpi) val (height, width) = - ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + ((windowHeight / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() to - ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + ((windowWidth / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) .roundToInt() .adjustToBlockSize() return ScreenshotRecorderConfig( recordingWidth = width, recordingHeight = height, - scaleFactorX = width.toFloat() / screenBounds.width(), - scaleFactorY = height.toFloat() / screenBounds.height(), + scaleFactorX = width.toFloat() / windowWidth, + scaleFactorY = height.toFloat() / windowHeight, frameRate = sessionReplay.frameRate, bitRate = sessionReplay.quality.bitRate ) @@ -345,3 +337,10 @@ public interface ScreenshotRecorderCallback { */ fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) } + +/** + * A callback to be invoked when once current window size is determined or changes + */ +public interface WindowCallback { + public fun onWindowSizeChanged(width: Int, height: Int) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 8f4b0526fcb..05762acc34b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,10 +1,15 @@ package io.sentry.android.replay import android.annotation.TargetApi +import android.graphics.Point import android.view.View +import android.view.ViewTreeObserver import io.sentry.SentryOptions import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.addOnPreDrawListenerSafe import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.hasSize +import io.sentry.android.replay.util.removeOnPreDrawListenerSafe import io.sentry.android.replay.util.scheduleAtFixedRateSafely import java.lang.ref.WeakReference import java.util.concurrent.Executors @@ -18,6 +23,7 @@ import java.util.concurrent.atomic.AtomicBoolean internal class WindowRecorder( private val options: SentryOptions, private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val windowCallback: WindowCallback, private val mainLooperHandler: MainLooperHandler, private val replayExecutor: ScheduledExecutorService ) : Recorder, OnRootViewsChangedListener { @@ -28,6 +34,7 @@ internal class WindowRecorder( private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() + private var lastKnownWindowSize: Point = Point() private val rootViewsLock = Any() private var recorder: ScreenshotRecorder? = null private var capturingTask: ScheduledFuture<*>? = null @@ -40,6 +47,7 @@ internal class WindowRecorder( if (added) { rootViews.add(WeakReference(root)) recorder?.bind(root) + determineWindowSize(root) } else { recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -47,6 +55,7 @@ internal class WindowRecorder( val newRoot = rootViews.lastOrNull()?.get() if (newRoot != null && root != newRoot) { recorder?.bind(newRoot) + determineWindowSize(newRoot) } else { Unit // synchronized block wants us to return something lol } @@ -54,19 +63,62 @@ internal class WindowRecorder( } } - override fun start(recorderConfig: ScreenshotRecorderConfig) { - if (isRecording.getAndSet(true)) { + fun determineWindowSize(root: View) { + if (root.hasSize()) { + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + } else { + root.addOnPreDrawListenerSafe(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val currentRoot = rootViews.lastOrNull()?.get() + // in case the root changed in the meantime, ignore the preDraw of the outdate root + if (root != currentRoot) { + root.removeOnPreDrawListenerSafe(this) + return true + } + if (root.hasSize()) { + root.removeOnPreDrawListenerSafe(this) + if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) { + lastKnownWindowSize.set(root.width, root.height) + windowCallback.onWindowSizeChanged(root.width, root.height) + } + } + return true + } + }) + } + } + + override fun start() { + isRecording.getAndSet(true) + } + + override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + if (!isRecording.get()) { return } - recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, replayExecutor, screenshotRecorderCallback) + recorder = ScreenshotRecorder( + config, + options, + mainLooperHandler, + replayExecutor, + screenshotRecorderCallback + ) + + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null) { + recorder?.bind(newRoot) + } // TODO: change this to use MainThreadHandler and just post on the main thread with delay // to avoid thread context switch every time capturingTask = capturer.scheduleAtFixedRateSafely( options, "$TAG.capture", 100L, // delay the first run by a bit, to allow root view listener to register - 1000L / recorderConfig.frameRate, + 1000L / config.frameRate, MILLISECONDS ) { recorder?.capture() @@ -76,15 +128,20 @@ internal class WindowRecorder( override fun resume() { recorder?.resume() } + override fun pause() { recorder?.pause() } - override fun stop() { + override fun reset() { + lastKnownWindowSize.set(0, 0) synchronized(rootViewsLock) { rootViews.forEach { recorder?.unbind(it.get()) } rootViews.clear() } + } + + override fun stop() { recorder?.close() recorder = null capturingTask?.cancel(false) @@ -93,6 +150,7 @@ internal class WindowRecorder( } override fun close() { + reset() stop() capturer.gracefullyShutdown(options) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fbc80565b1b..19a926b7682 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -5,6 +5,7 @@ import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub +import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER @@ -60,10 +61,10 @@ internal abstract class BaseCaptureStrategy( protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + protected var recorderConfig: ScreenshotRecorderConfig? by persistableAtomicNullable(propertyName = "") { _, _, newValue -> if (newValue == null) { // recorderConfig is only nullable on init, but never after - return@persistableAtomic + return@persistableAtomicNullable } cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) @@ -84,7 +85,6 @@ internal abstract class BaseCaptureStrategy( protected val currentEvents: Deque = ConcurrentLinkedDeque() override fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, replayType: ReplayType? @@ -94,7 +94,6 @@ internal abstract class BaseCaptureStrategy( this.currentReplayId = replayId this.currentSegment = segmentId this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER) - this.recorderConfig = recorderConfig segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -108,7 +107,6 @@ internal abstract class BaseCaptureStrategy( override fun stop() { cache?.close() - currentSegment = -1 replayStartTimestamp.set(0) segmentTimestamp = null currentReplayId = SentryId.EMPTY_ID @@ -121,10 +119,10 @@ internal abstract class BaseCaptureStrategy( segmentId: Int, height: Int, width: Int, + frameRate: Int, + bitRate: Int, replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, - frameRate: Int = recorderConfig.frameRate, - bitRate: Int = recorderConfig.bitRate, screenAtStart: String? = this.screenAtStart, breadcrumbs: List? = null, events: Deque = this.currentEvents @@ -152,9 +150,11 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - val rrwebEvents = gestureConverter.convert(event, recorderConfig) - if (rrwebEvents != null) { - currentEvents += rrwebEvents + recorderConfig?.let { config -> + val rrwebEvents = gestureConverter.convert(event, config) + if (rrwebEvents != null) { + currentEvents += rrwebEvents + } } } @@ -183,7 +183,11 @@ internal abstract class BaseCaptureStrategy( task() } } else { - task() + try { + task() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $TAG.runInBackground", e) + } } } @@ -205,9 +209,4 @@ internal abstract class BaseCaptureStrategy( } ): ReadWriteProperty = persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty - - private inline fun persistableAtomic( - crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit - ): ReadWriteProperty = - persistableAtomicNullable(null, "", onChange) as ReadWriteProperty } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 996e31afbd8..298312e7e22 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -57,6 +57,7 @@ internal class BufferCaptureStrategy( val replayCacheDir = cache?.replayCacheDir replayExecutor.submitSafely(options, "$TAG.stop") { FileUtils.deleteRecursively(replayCacheDir) + currentSegment = -1 } super.stop() } @@ -131,7 +132,7 @@ internal class BufferCaptureStrategy( } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) - captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) + captureStrategy.start(segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) return captureStrategy } @@ -189,6 +190,14 @@ internal class BufferCaptureStrategy( } private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val currentConfig = recorderConfig + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not creating segment for task: $taskName" + ) + return + } val errorReplayDuration = options.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { @@ -197,15 +206,21 @@ internal class BufferCaptureStrategy( } else { DateUtils.getDateTime(now - errorReplayDuration) } - val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - val height = this.recorderConfig.recordingHeight - val width = this.recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate + ) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 660a366ecd2..c09e4b67082 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -31,7 +31,6 @@ internal interface CaptureStrategy { var segmentTimestamp: Date? fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, replayId: SentryId = SentryId(), replayType: ReplayType? = null @@ -58,6 +57,10 @@ internal interface CaptureStrategy { companion object { private const val BREADCRUMB_START_OFFSET = 100L + // 5 minutes, otherwise relay will just drop it. Can prevent the case where the device + // time is wrong and the segment is too long. + private const val MAX_SEGMENT_DURATION = 1000L * 60 * 5 + fun createSegment( hub: IHub?, options: SentryOptions, @@ -76,7 +79,7 @@ internal interface CaptureStrategy { events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( - duration, + minOf(duration, MAX_SEGMENT_DURATION), currentSegmentTimestamp.time, segmentId, height, @@ -179,7 +182,9 @@ internal interface CaptureStrategy { recordingPayload += rrwebEvent // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation" && + rrwebEvent.data?.getOrElse("to", { null }) is String + ) { urls.add(rrwebEvent.data!!["to"] as String) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 03ca0cdf552..06fa13bf003 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -29,12 +29,11 @@ internal class SessionCaptureStrategy( } override fun start( - recorderConfig: ScreenshotRecorderConfig, segmentId: Int, replayId: SentryId, replayType: ReplayType? ) { - super.start(recorderConfig, segmentId, replayId, replayType) + super.start(segmentId, replayId, replayType) // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { @@ -60,6 +59,7 @@ internal class SessionCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(hub) } + currentSegment = -1 FileUtils.deleteRecursively(replayCacheDir) } hub?.configureScope { it.replayId = SentryId.EMPTY_ID } @@ -74,9 +74,8 @@ internal class SessionCaptureStrategy( override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured + val currentConfig = recorderConfig val frameTimestamp = dateProvider.currentTimeMillis - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.store(frameTimestamp) @@ -91,6 +90,14 @@ internal class SessionCaptureStrategy( return@submitSafely } + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not recording frame" + ) + return@submitSafely + } + val now = dateProvider.currentTimeMillis if ((now - currentSegmentTimestamp.time >= options.sessionReplay.sessionSegmentDuration)) { val segment = @@ -99,8 +106,10 @@ internal class SessionCaptureStrategy( currentSegmentTimestamp, currentReplayId, currentSegment, - height, - width + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate ) if (segment is ReplaySegment.Created) { segment.capture(hub) @@ -135,16 +144,31 @@ internal class SessionCaptureStrategy( override fun convert(): CaptureStrategy = this private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val currentConfig = recorderConfig + if (currentConfig == null) { + options.logger.log( + DEBUG, + "Recorder config is not set, not creating segment for task: $taskName" + ) + return + } + val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp ?: return - val segmentId = currentSegment val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal( + duration, + currentSegmentTimestamp, + replayId, + currentSegment, + currentConfig.recordingHeight, + currentConfig.recordingWidth, + currentConfig.frameRate, + currentConfig.bitRate + ) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt new file mode 100644 index 00000000000..2de284cf642 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/SystemProperties.kt @@ -0,0 +1,21 @@ +package io.sentry.android.replay.util + +import android.os.Build + +internal object SystemProperties { + enum class Property { + SOC_MODEL, + SOC_MANUFACTURER + } + + fun get(key: Property, defaultValue: String = ""): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + when (key) { + Property.SOC_MODEL -> Build.SOC_MODEL + Property.SOC_MANUFACTURER -> Build.SOC_MANUFACTURER + } + } else { + defaultValue + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index f3e667dc320..73049620aa0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -184,12 +184,44 @@ internal fun View?.addOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListen if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.addOnDrawListener(listener) + try { + viewTreeObserver.addOnDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } } internal fun View?.removeOnDrawListenerSafe(listener: ViewTreeObserver.OnDrawListener) { if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { return } - viewTreeObserver.removeOnDrawListener(listener) + try { + viewTreeObserver.removeOnDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } +} + +internal fun View?.addOnPreDrawListenerSafe(listener: ViewTreeObserver.OnPreDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + try { + viewTreeObserver.addOnPreDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } } + +internal fun View?.removeOnPreDrawListenerSafe(listener: ViewTreeObserver.OnPreDrawListener) { + if (this == null || viewTreeObserver == null || !viewTreeObserver.isAlive) { + return + } + try { + viewTreeObserver.removeOnPreDrawListener(listener) + } catch (_: IllegalStateException) { + // viewTreeObserver is already dead + } +} + +internal fun View.hasSize(): Boolean = width > 0 && height > 0 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 211decc098d..2e402df7d69 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -39,6 +39,7 @@ import android.os.Build import android.view.Surface import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions +import io.sentry.android.replay.util.SystemProperties import java.io.File import java.nio.ByteBuffer import kotlin.LazyThreadSafetyMode.NONE @@ -155,9 +156,21 @@ internal class SimpleVideoEncoder( } fun encode(image: Bitmap) { - // it seems that Xiaomi devices have problems with hardware canvas, so we have to use - // lockCanvas instead https://stackoverflow.com/a/73520742 - val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) { + /** it seems that Xiaomi devices have problems with hardware canvas, so we have to use + * lockCanvas instead https://stackoverflow.com/a/73520742 + * --- + * Same for Motorola devices. + * --- + * As for the Spreadtrum/Unisoc chipset, it can be spread across various devices, so we have + * to check the SOC_MANUFACTURER property, as the manufacturer name might have changed. + * https://github.com/getsentry/sentry-android-gradle-plugin/issues/861#issuecomment-2867021256 + */ + val canvas = if ( + Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || + Build.MANUFACTURER.contains("motorola", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER).equals("spreadtrum", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER).equals("unisoc", ignoreCase = true) + ) { surface?.lockCanvas(null) } else { surface?.lockHardwareCanvas() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index a4d82159da5..a2a366ffa10 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -11,9 +11,11 @@ import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.Owner import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsConfiguration import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.TextUnit import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryReplayOptions @@ -28,25 +30,51 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.lang.ref.WeakReference +import java.lang.reflect.Method @TargetApi(26) internal object ComposeViewHierarchyNode { + private val getSemanticsConfigurationMethod: Method? by lazy { + try { + return@lazy LayoutNode::class.java.getDeclaredMethod("getSemanticsConfiguration").apply { + isAccessible = true + } + } catch (_: Throwable) { + // ignore, as this method may not be available + } + return@lazy null + } + + private var semanticsRetrievalErrorLogged: Boolean = false + + @JvmStatic + internal fun retrieveSemanticsConfiguration(node: LayoutNode): SemanticsConfiguration? { + // Jetpack Compose 1.8 or newer provides SemanticsConfiguration via SemanticsInfo + // See https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt + // and https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsInfo.kt + getSemanticsConfigurationMethod?.let { + return it.invoke(node) as SemanticsConfiguration? + } + + // for backwards compatibility + return node.collapsedSemantics + } + /** * Since Compose doesn't have a concept of a View class (they are all composable functions), * we need to map the semantics node to a corresponding old view system class. */ - private fun LayoutNode.getProxyClassName(isImage: Boolean): String { + private fun getProxyClassName(isImage: Boolean, config: SemanticsConfiguration?): String { return when { isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME - collapsedSemantics?.contains(SemanticsProperties.Text) == true || - collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + config != null && (config.contains(SemanticsProperties.Text) || config.contains(SemanticsActions.SetText) || config.contains(SemanticsProperties.EditableText)) -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME else -> "android.view.View" } } - private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { - val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + private fun SemanticsConfiguration?.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = this?.getOrNull(SentryReplayModifiers.SentryPrivacy) if (sentryPrivacyModifier == "unmask") { return false } @@ -55,7 +83,7 @@ internal object ComposeViewHierarchyNode { return true } - val className = getProxyClassName(isImage) + val className = getProxyClassName(isImage, this) if (options.sessionReplay.unmaskViewClasses.contains(className)) { return false } @@ -81,15 +109,53 @@ internal object ComposeViewHierarchyNode { _rootCoordinates = WeakReference(node.coordinates.findRootCoordinates()) } - val semantics = node.collapsedSemantics val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates?.get()) + val semantics: SemanticsConfiguration? + + try { + semantics = retrieveSemanticsConfiguration(node) + } catch (t: Throwable) { + if (!semanticsRetrievalErrorLogged) { + semanticsRetrievalErrorLogged = true + options.logger.log( + SentryLevel.ERROR, + t, + """ + Error retrieving semantics information from Compose tree. Most likely you're using + an unsupported version of androidx.compose.ui:ui. The supported + version range is 1.5.0 - 1.8.0. + If you're using a newer version, please open a github issue with the version + you're using, so we can add support for it. + """.trimIndent() + ) + } + + // If we're unable to retrieve the semantics configuration + // we should play safe and mask the whole node. + return GenericViewHierarchyNode( + x = visibleRect.left.toFloat(), + y = visibleRect.top.toFloat(), + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldMask = true, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = !node.outerCoordinator.isTransparent() && visibleRect.height() > 0 && visibleRect.width() > 0, + visibleRect = visibleRect + ) + } + val isVisible = !node.outerCoordinator.isTransparent() && (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 - val isEditable = semantics?.contains(SemanticsActions.SetText) == true + val isEditable = semantics?.contains(SemanticsActions.SetText) == true || + semantics?.contains(SemanticsProperties.EditableText) == true + return when { semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { - val shouldMask = isVisible && node.shouldMask(isImage = false, options) + val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) parent?.setImportantForCaptureToAncestors(true) // TODO: if we get reports that it's slow, we can drop this, and just mask @@ -100,14 +166,19 @@ internal object ComposeViewHierarchyNode { ?.invoke(textLayoutResults) val (color, hasFillModifier) = node.findTextAttributes() - var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + val textLayoutResult = textLayoutResults.firstOrNull() + var textColor = textLayoutResult?.layoutInput?.style?.color if (textColor?.isUnspecified == true) { textColor = color } - // TODO: support multiple text layouts + val isLaidOut = textLayoutResult?.layoutInput?.style?.fontSize != TextUnit.Unspecified // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) TextViewHierarchyNode( - layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + layout = if (textLayoutResult != null && !isEditable && isLaidOut) { + ComposeTextLayout(textLayoutResult, hasFillModifier) + } else { + null + }, dominantColor = textColor?.toArgb()?.toOpaque(), x = visibleRect.left.toFloat(), y = visibleRect.top.toFloat(), @@ -125,7 +196,7 @@ internal object ComposeViewHierarchyNode { else -> { val painter = node.findPainter() if (painter != null) { - val shouldMask = isVisible && node.shouldMask(isImage = true, options) + val shouldMask = isVisible && semantics.shouldMask(isImage = true, options) parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( @@ -142,7 +213,7 @@ internal object ComposeViewHierarchyNode { visibleRect = visibleRect ) } else { - val shouldMask = isVisible && node.shouldMask(isImage = false, options) + val shouldMask = isVisible && semantics.shouldMask(isImage = false, options) // TODO: this currently does not support embedded AndroidViews, we'd have to // TODO: traverse the ViewHierarchyNode here again. For now we can recommend diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index b2c8836d40f..a3e17d4f732 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -285,6 +285,21 @@ class ReplayCacheTest { assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) } + @Test + fun `when file does not exist upon persisting creates it`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId + ) + + replayCache.ongoingSegmentFile?.delete() + + replayCache.persistSegmentValues("key", "value") + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key=value", segmentValues[0]) + } + @Test fun `stores segment key value pairs`() { val replayId = SentryId() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 4183a780b8e..862db6d6244 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -32,6 +32,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.tape.QueueFile import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent @@ -59,6 +60,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -108,7 +110,6 @@ class ReplayIntegrationTest { isRateLimited: Boolean = false, recorderProvider: (() -> Recorder)? = null, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, - recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { @@ -126,7 +127,6 @@ class ReplayIntegrationTest { context, dateProvider, recorderProvider, - recorderConfigProvider = recorderConfigProvider, replayCacheProvider = { _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider, gestureRecorderProvider = gestureRecorderProvider @@ -184,7 +184,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start(any(), any(), any(), anyOrNull()) + verify(captureStrategy, never()).start(any(), any(), anyOrNull()) } @Test @@ -208,7 +208,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, times(1)).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -224,7 +223,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, never()).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -240,7 +238,6 @@ class ReplayIntegrationTest { replay.start() verify(captureStrategy, times(1)).start( - any(), eq(0), argThat { this != SentryId.EMPTY_ID }, anyOrNull() @@ -255,7 +252,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(recorder).start(any()) + verify(recorder).start() } @Test @@ -277,6 +274,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.pause() replay.resume() verify(captureStrategy).resume() @@ -429,25 +427,21 @@ class ReplayIntegrationTest { @Test fun `onConfigurationChanged stops and restarts recorder with a new recorder config`() { - var configChanged = false val recorderConfig = mock() val captureStrategy = mock() val recorder = mock() val replay = fixture.getSut( context, recorderProvider = { recorder }, - replayCaptureStrategyProvider = { captureStrategy }, - recorderConfigProvider = { configChanged = it; recorderConfig } + replayCaptureStrategyProvider = { captureStrategy } ) replay.register(fixture.hub, fixture.options) replay.start() - replay.onConfigurationChanged(mock()) + replay.onConfigurationChanged(recorderConfig) - verify(recorder).stop() verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) - verify(recorder, times(2)).start(eq(recorderConfig)) - assertTrue(configChanged) + verify(recorder, times(1)).start() } @Test @@ -455,6 +449,7 @@ class ReplayIntegrationTest { val oldReplayId = SentryId() fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) val oldReplay = File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } @@ -471,17 +466,18 @@ class ReplayIntegrationTest { it.writeText("\"$oldReplayId\"") } val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + val queueFile = QueueFile.Builder(breadcrumbsFile).build() + val baos = ByteArrayOutputStream() fixture.options.serializer.serialize( - listOf( - Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { - category = "navigation" - type = "navigation" - setData("from", "from") - setData("to", "to") - } - ), - breadcrumbsFile.writer() + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(oldReplay, ONGOING_SEGMENT).also { it.writeText( """ @@ -646,6 +642,7 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) replay.onConnectionStatusChanged(CONNECTED) verify(recorder).resume() @@ -677,12 +674,182 @@ class ReplayIntegrationTest { context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }, - isRateLimited = false + isRateLimited = true ) replay.register(fixture.hub, fixture.options) replay.start() + replay.onRateLimitChanged(fixture.rateLimiter) + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) + replay.onRateLimitChanged(fixture.rateLimiter) + + verify(recorder).resume() + } + + @Test + fun `closed replay cannot be started`() { + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + replay.start() + replay.close() + + replay.start() + + assertFalse(replay.isRecording) + } + + @Test + fun `if recording is paused in configChanges re-pauses it again`() { + val recorderConfig = mock() + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onConfigurationChanged(recorderConfig) + + verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) + verify(recorder, times(1)).start() + verify(recorder, times(2)).pause() + } + + @Test + fun `onTouchEvent does nothing when not started or resumed`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + } + + @Test + fun `when paused manually onConnectionStatusChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.onConnectionStatusChanged(DISCONNECTED) + replay.pause() + replay.onConnectionStatusChanged(CONNECTED) + + verify(recorder, never()).resume() + } + + @Test + fun `when paused manually onRateLimitChanged does not resume`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.onRateLimitChanged(fixture.rateLimiter) + replay.pause() + whenever(fixture.rateLimiter.isActiveForCategory(any())).thenReturn(false) + replay.onRateLimitChanged(fixture.rateLimiter) + + verify(recorder, never()).resume() + } + + @Test + fun `when rate limit is active manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isRateLimited = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when no connection manual resume does nothing`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + isOffline = true + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + verify(recorder, never()).resume() + } + + @Test + fun `when already paused does not pause again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.pause() + + verify(recorder).pause() + } + + @Test + fun `when already resumed does not resume again`() { + val captureStrategy = getSessionCaptureStrategy(fixture.options) + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.pause() + replay.resume() + + replay.resume() verify(recorder).resume() } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index ae817a17596..ec8c4eceea7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -57,14 +57,12 @@ class ReplayIntegrationWithRecorderTest { fun getSut( context: Context, recorder: Recorder, - recorderConfig: ScreenshotRecorderConfig, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, - recorderProvider = { recorder }, - recorderConfigProvider = { recorderConfig } + recorderProvider = { recorder } ) } } @@ -90,18 +88,22 @@ class ReplayIntegrationWithRecorderTest { System.currentTimeMillis() + fixture.options.sessionReplay.sessionSegmentDuration } + fixture.options.sessionReplay.isTrackConfiguration = false fixture.options.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) val recorder = object : Recorder { var state: LifecycleState = INITALIZED - override fun start(recorderConfig: ScreenshotRecorderConfig) { + override fun start() { state = STARTED } + override fun onConfigurationChanged(config: ScreenshotRecorderConfig) { + // no-op + } + override fun resume() { state = RESUMED } @@ -110,6 +112,10 @@ class ReplayIntegrationWithRecorderTest { state = PAUSED } + override fun reset() { + state = STOPPED + } + override fun stop() { state = STOPPED } @@ -119,19 +125,33 @@ class ReplayIntegrationWithRecorderTest { } } - replay = fixture.getSut(context, recorder, recorderConfig, dateProvider) + replay = fixture.getSut(context, recorder, dateProvider) replay.register(fixture.hub, fixture.options) assertEquals(INITALIZED, recorder.state) replay.start() + + // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed + // inside recorder.start() + val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + replay.onWindowSizeChanged(640, 480) assertEquals(STARTED, recorder.state) + replay.pause() + assertEquals(PAUSED, recorder.state) + replay.resume() assertEquals(RESUMED, recorder.state) - replay.pause() - assertEquals(PAUSED, recorder.state) + // this should be ignored, as no manual onConfigurationChanged was called so far + replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) replay.stop() assertEquals(STOPPED, recorder.state) @@ -139,14 +159,10 @@ class ReplayIntegrationWithRecorderTest { // start again and capture some frames replay.start() - // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed - // inside recorder.start() - val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + // E.g. Flutter will trigger onConfigurationChanged + val flutterConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + replay.onConfigurationChanged(flutterConfig) - screenshot.outputStream().use { - Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) - it.flush() - } replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) // verify @@ -161,12 +177,12 @@ class ReplayIntegrationWithRecorderTest { }, check { val metaEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(200, metaEvents?.first()?.height) - assertEquals(100, metaEvents?.first()?.width) + assertEquals(flutterConfig.recordingHeight, metaEvents?.first()?.height) + assertEquals(flutterConfig.recordingWidth, metaEvents?.first()?.width) val videoEvents = it.replayRecording?.payload?.filterIsInstance() - assertEquals(200, videoEvents?.first()?.height) - assertEquals(100, videoEvents?.first()?.width) + assertEquals(flutterConfig.recordingHeight, videoEvents?.first()?.height) + assertEquals(flutterConfig.recordingWidth, videoEvents?.first()?.width) assertEquals(5000, videoEvents?.first()?.durationMs) assertEquals(5, videoEvents?.first()?.frameCount) assertEquals(1, videoEvents?.first()?.frameRate) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt new file mode 100644 index 00000000000..c9892374526 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayLifecycleTest.kt @@ -0,0 +1,120 @@ +package io.sentry.android.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReplayLifecycleTest { + @Test + fun `verify initial state`() { + val lifecycle = ReplayLifecycle() + assertEquals(ReplayState.INITIAL, lifecycle.currentState) + } + + @Test + fun `test transitions from INITIAL state`() { + val lifecycle = ReplayLifecycle() + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + } + + @Test + fun `test transitions from STARTED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STARTED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from RESUMED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.RESUMED + + assertTrue(lifecycle.isAllowed(ReplayState.PAUSED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from PAUSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.PAUSED + + assertTrue(lifecycle.isAllowed(ReplayState.RESUMED)) + assertTrue(lifecycle.isAllowed(ReplayState.STOPPED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from STOPPED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.STOPPED + + assertTrue(lifecycle.isAllowed(ReplayState.STARTED)) + assertTrue(lifecycle.isAllowed(ReplayState.CLOSED)) + + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + } + + @Test + fun `test transitions from CLOSED state`() { + val lifecycle = ReplayLifecycle() + lifecycle.currentState = ReplayState.CLOSED + + assertFalse(lifecycle.isAllowed(ReplayState.INITIAL)) + assertFalse(lifecycle.isAllowed(ReplayState.STARTED)) + assertFalse(lifecycle.isAllowed(ReplayState.RESUMED)) + assertFalse(lifecycle.isAllowed(ReplayState.PAUSED)) + assertFalse(lifecycle.isAllowed(ReplayState.STOPPED)) + assertFalse(lifecycle.isAllowed(ReplayState.CLOSED)) + } + + @Test + fun `test touch recording is allowed only in STARTED and RESUMED states`() { + val lifecycle = ReplayLifecycle() + + // Initial state doesn't allow touch recording + assertFalse(lifecycle.isTouchRecordingAllowed()) + + // STARTED state allows touch recording + lifecycle.currentState = ReplayState.STARTED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // RESUMED state allows touch recording + lifecycle.currentState = ReplayState.RESUMED + assertTrue(lifecycle.isTouchRecordingAllowed()) + + // Other states don't allow touch recording + val otherStates = listOf( + ReplayState.INITIAL, + ReplayState.PAUSED, + ReplayState.STOPPED, + ReplayState.CLOSED + ) + + otherStates.forEach { state -> + lifecycle.currentState = state + assertFalse(lifecycle.isTouchRecordingAllowed()) + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 9bd8e2038dc..136d568c403 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -78,7 +78,6 @@ class ReplaySmokeTest { context, dateProvider, recorderProvider = null, - recorderConfigProvider = null, replayCaptureStrategyProvider = null, replayCacheProvider = null, mainLooperHandler = mock { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 29c777e171f..62b30d09f0e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -123,7 +123,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -135,7 +135,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -149,7 +149,8 @@ class BufferCaptureStrategyTest { @Test fun `pause creates but does not capture current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId()) + strategy.start(0, SentryId()) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.pause() @@ -166,7 +167,7 @@ class BufferCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) strategy.stop() @@ -185,7 +186,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -199,7 +200,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -210,7 +211,8 @@ class BufferCaptureStrategyTest { @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) strategy.onConfigurationChanged(newConfig) @@ -224,7 +226,7 @@ class BufferCaptureStrategyTest { @Test fun `convert does nothing when process is terminating`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(true) {} @@ -235,7 +237,7 @@ class BufferCaptureStrategyTest { @Test fun `convert converts to session strategy and sets replayId to scope`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() val converted = strategy.convert() assertTrue(converted is SessionCaptureStrategy) @@ -245,7 +247,7 @@ class BufferCaptureStrategyTest { @Test fun `convert persists buffer replayType when converting to session strategy`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() val converted = strategy.convert() assertEquals( @@ -257,7 +259,7 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay does not replayId to scope when not sampled`() { val strategy = fixture.getSut(onErrorSampleRate = 0.0) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(false) {} @@ -268,7 +270,9 @@ class BufferCaptureStrategyTest { fun `captureReplay sets replayId to scope and captures buffered segments`() { var called = false val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + strategy.pause() strategy.captureReplay(false) { @@ -284,7 +288,9 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay sets new segment timestamp to new strategy after successful creation`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + val oldTimestamp = strategy.segmentTimestamp strategy.captureReplay(false) { newTimestamp -> @@ -299,7 +305,7 @@ class BufferCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals( replayId.toString(), diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index dfe4137fb06..f5cc1a24894 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -122,7 +122,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals(replayId, fixture.scope.replayId) assertEquals(replayId, strategy.currentReplayId) @@ -134,7 +134,8 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) @@ -164,7 +165,8 @@ class SessionCaptureStrategyTest { @Test fun `pause creates and captures current segment`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig, 0, SentryId()) + strategy.start(0, SentryId()) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.pause() @@ -184,7 +186,8 @@ class SessionCaptureStrategyTest { File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } val strategy = fixture.getSut(replayCacheDir = currentReplay) - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.stop() @@ -204,7 +207,7 @@ class SessionCaptureStrategyTest { @Test fun `captureReplay does nothing for non-crashed event`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(false) {} @@ -218,7 +221,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() strategy.captureReplay(true) {} strategy.onScreenshotRecorded(mock()) {} @@ -233,7 +236,8 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut( dateProvider = { now } ) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) { frameTimestamp -> assertEquals(now, frameTimestamp) @@ -272,7 +276,8 @@ class SessionCaptureStrategyTest { } } ) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(mock()) strategy.onScreenshotRecorded(mock()) {} @@ -282,7 +287,8 @@ class SessionCaptureStrategyTest { @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) strategy.onConfigurationChanged(newConfig) @@ -317,7 +323,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) @@ -336,6 +343,31 @@ class SessionCaptureStrategyTest { ) } + @Test + fun `does not throw when navigation destination is not a String`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertNull(it.urls?.firstOrNull()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertNull(breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + @Test fun `sets screen from scope as replay url`() { fixture.scope.screen = "MainActivity" @@ -343,7 +375,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} @@ -364,7 +397,7 @@ class SessionCaptureStrategyTest { val strategy = fixture.getSut() val replayId = SentryId() - strategy.start(fixture.recorderConfig, 0, replayId) + strategy.start(0, replayId) assertEquals( replayId.toString(), @@ -384,7 +417,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} @@ -428,7 +462,8 @@ class SessionCaptureStrategyTest { val now = System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) val strategy = fixture.getSut(dateProvider = { now }) - strategy.start(fixture.recorderConfig) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) strategy.onScreenshotRecorded(mock()) {} verify(fixture.hub).captureReplay( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt new file mode 100644 index 00000000000..530c124af4f --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ViewsTest.kt @@ -0,0 +1,36 @@ +package io.sentry.android.replay.util + +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewsTest { + @Test + fun `hasSize returns true for positive values`() { + val view = View(ApplicationProvider.getApplicationContext()) + view.right = 100 + view.bottom = 100 + assertTrue(view.hasSize()) + } + + @Test + fun `hasSize returns false for null values`() { + val view = View(ApplicationProvider.getApplicationContext()) + view.right = 0 + view.bottom = 0 + assertFalse(view.hasSize()) + } + + @Test + fun `hasSize returns false for negative values`() { + val view = View(ApplicationProvider.getApplicationContext()) + view.right = -1 + view.bottom = -1 + assertFalse(view.hasSize()) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 8fa3106058f..3675b6da848 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -1,8 +1,13 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package io.sentry.android.replay.viewhierarchy import android.app.Activity import android.net.Uri import android.os.Bundle +import android.os.Looper +import android.view.View +import android.view.ViewGroup import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement @@ -14,11 +19,17 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.editableText import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.test.ext.junit.runners.AndroidJUnit4 import coil.compose.AsyncImage import io.sentry.SentryOptions @@ -31,14 +42,23 @@ import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.Assert.assertThrows import org.junit.Before import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -49,7 +69,9 @@ class ComposeMaskingOptionsTest { fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") ComposeMaskingOptionsActivity.textModifierApplier = null + ComposeMaskingOptionsActivity.textFieldModifierApplier = null ComposeMaskingOptionsActivity.containerModifierApplier = null + ComposeMaskingOptionsActivity.fontSizeApplier = null } @Test @@ -63,8 +85,40 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] assertTrue(textNodes.all { it.shouldMask }) - // just a sanity check for parsing the tree - assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + // no fontSize specified - we don't use the text layout + assertNull(textNodes.first().layout) + } + + @Test + fun `when text is laid out nodes use it`() { + ComposeMaskingOptionsActivity.fontSizeApplier = { 20.sp } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + val options = SentryOptions().apply { + sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + // the text should be laid out when fontSize is specified + assertEquals("Random repo", (textNodes.first().layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text) + } + + @Test + fun `when text input field is readOnly still masks it`() { + ComposeMaskingOptionsActivity.textFieldModifierApplier = { + // newer versions of compose basically do this when a TextField is readOnly + Modifier.clearAndSetSemantics { editableText = AnnotatedString("Placeholder") } + } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + + val options = SentryOptions().apply { + sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertTrue(textNodes[1].shouldMask) } @Test @@ -93,6 +147,44 @@ class ComposeMaskingOptionsTest { assertTrue(imageNodes.all { it.shouldMask }) } + @Test + fun `when retrieving the semantics fails, a node should be masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + shadowOf(Looper.getMainLooper()).idle() + val options = SentryOptions() + + Mockito.mockStatic(ComposeViewHierarchyNode.javaClass) + .use { mock: MockedStatic -> + mock.`when` { + ComposeViewHierarchyNode.retrieveSemanticsConfiguration(any()) + }.thenThrow(RuntimeException()) + + val root = activity.get().window.decorView + val composeView = root.lookupComposeView() + assertNotNull(composeView) + + val rootNode = GenericViewHierarchyNode(0f, 0f, 0, 0, 1.0f, -1, shouldMask = true) + ComposeViewHierarchyNode.fromView(composeView, rootNode, options) + + assertEquals(1, rootNode.children?.size) + + rootNode.traverse { node -> + assertTrue(node.shouldMask) + true + } + } + } + + @Test + fun `when retrieving the semantics fails, an error is thrown`() { + val node = mock() + whenever(node.collapsedSemantics).thenThrow(RuntimeException("Compose Runtime Error")) + + assertThrows(RuntimeException::class.java) { + ComposeViewHierarchyNode.retrieveSemanticsConfiguration(node) + } + } + @Test fun `when maskAllImages is set to false all Image nodes are unmasked`() { val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() @@ -195,13 +287,31 @@ class ComposeMaskingOptionsTest { } return nodes } + + private fun View.lookupComposeView(): View? { + if (this.javaClass.name.contains("AndroidComposeView")) { + return this + } + if (this is ViewGroup) { + for (i in 0 until childCount) { + val child = getChildAt(i) + val composeView = child.lookupComposeView() + if (composeView != null) { + return composeView + } + } + } + return null + } } private class ComposeMaskingOptionsActivity : ComponentActivity() { companion object { var textModifierApplier: (() -> Modifier)? = null + var textFieldModifierApplier: (() -> Modifier)? = null var containerModifierApplier: (() -> Modifier)? = null + var fontSizeApplier: (() -> TextUnit)? = null } override fun onCreate(savedInstanceState: Bundle?) { @@ -221,11 +331,12 @@ private class ComposeMaskingOptionsActivity : ComponentActivity() { contentDescription = null, modifier = Modifier.padding(vertical = 16.dp) ) + Text("Random repo", fontSize = fontSizeApplier?.invoke() ?: TextUnit.Unspecified) TextField( + modifier = textFieldModifierApplier?.invoke() ?: Modifier, value = TextFieldValue("Placeholder"), onValueChange = { _ -> } ) - Text("Random repo") Button( onClick = {}, modifier = Modifier diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java index aaf085f4841..99cc5414419 100644 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -39,7 +39,7 @@ public ComposeGestureTargetLocator(final @NotNull ILogger logger) { @Override public @Nullable UiElement locate( - @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable Object root, float x, float y, UiElement.Type targetType) { // lazy init composeHelper as it's using some reflection under the hood if (composeHelper == null) { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 114c08a22ff..a31027a5a3a 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -60,6 +60,11 @@ kotlin { implementation(Config.TestLibs.mockitoKotlin) implementation(Config.TestLibs.mockitoInline) implementation(Config.Libs.composeNavigation) + implementation(Config.TestLibs.robolectric) + implementation(Config.TestLibs.androidxRunner) + implementation(Config.TestLibs.androidxJunit) + implementation(Config.TestLibs.androidxTestRules) + implementation(Config.TestLibs.composeUiTestJunit4) } } } diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index f1f43c9c8bb..e2b7bb07192 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -1,8 +1,13 @@ package io.sentry.compose import androidx.compose.ui.Modifier +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.SemanticsPropertyReceiver public object SentryModifier { @@ -19,11 +24,41 @@ public object SentryModifier { ) @JvmStatic - public fun Modifier.sentryTag(tag: String): Modifier { - return semantics( - properties = { - this[SentryTag] = tag + public fun Modifier.sentryTag(tag: String): Modifier = + this then SentryTagModifierNodeElement(tag) + + private data class SentryTagModifierNodeElement(val tag: String) : + ModifierNodeElement(), SemanticsModifier { + + override val semanticsConfiguration: SemanticsConfiguration = + SemanticsConfiguration().also { + it[SentryTag] = tag } - ) + + override fun create(): SentryTagModifierNode = SentryTagModifierNode(tag) + + override fun update(node: SentryTagModifierNode) { + node.tag = tag + } + + override fun InspectorInfo.inspectableProperties() { + name = "sentryTag" + properties["tag"] = tag + } + } + + private class SentryTagModifierNode(var tag: String) : + Modifier.Node(), + SemanticsModifierNode { + + override val shouldClearDescendantSemantics: Boolean + get() = false + + override val shouldMergeDescendantSemantics: Boolean + get() = false + + override fun SemanticsPropertyReceiver.applySemantics() { + this[SentryTag] = tag + } } } diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt new file mode 100644 index 00000000000..38aa2585d3f --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt @@ -0,0 +1,59 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.compose.SentryModifier.sentryTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryModifierComposeTest { + + companion object { + private const val TAG_VALUE = "ExampleTagValue" + } + + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager).addActivityIfNotPresent( + ComponentName( + appContext.packageName, + ComponentActivity::class.java.name + ) + ) + } + } + + @get:Rule(order = 2) + val rule = createComposeRule() + + @Test + fun sentryModifierAppliesTag() { + rule.setContent { + Box(modifier = Modifier.sentryTag(TAG_VALUE)) + } + rule.onNode( + SemanticsMatcher(TAG_VALUE) { + it.config.find { (key, _) -> key.name == SentryModifier.TAG }?.value == TAG_VALUE + } + ).assertExists() + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index d8ae6c709d9..5860c3863eb 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -6,8 +6,6 @@ - - diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 33dd35f9867..78416f8db34 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -3,6 +3,7 @@ import android.content.Intent; import android.os.Bundle; import android.os.Handler; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Attachment; import io.sentry.ISpan; @@ -257,6 +258,19 @@ public void run() { binding.openMetrics.setOnClickListener( view -> startActivity(new Intent(this, MetricsActivity.class))); + binding.showDialog.setOnClickListener( + view -> { + new AlertDialog.Builder(MainActivity.this) + .setTitle("Example Title") + .setMessage("Example Message") + .setPositiveButton( + "Close", + (dialog, which) -> { + dialog.dismiss(); + }) + .show(); + }); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 3d2e670495d..7a699a94f33 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -8,9 +8,18 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -55,11 +64,14 @@ class ComposeActivity : ComponentActivity() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Landing( navigateGithub: () -> Unit, navigateGithubWithArgs: () -> Unit ) { + var showDialog by remember { mutableStateOf(false) } + SentryTraced(tag = "buttons_page") { Column( verticalArrangement = Arrangement.Center, @@ -92,6 +104,46 @@ fun Landing( Text("Crash from Compose") } } + SentryTraced(tag = "button_dialog") { + Button( + onClick = { + showDialog = true + }, + modifier = Modifier + .testTag("button_show_dialog") + .padding(top = 32.dp) + ) { + Text("Show Dialog", modifier = Modifier.sentryReplayUnmask()) + } + } + if (showDialog) { + BasicAlertDialog( + onDismissRequest = { + showDialog = false + }, + content = { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding(20.dp), + content = { + Text( + "Dialog Title", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.size(20.dp)) + Text("Dialog Content") + } + ) + } + } + ) + } } } } @@ -106,7 +158,11 @@ fun Github( val scope = rememberCoroutineScope() LaunchedEffect(perPage) { - result = GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + result = try { + GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + } catch (e: Throwable) { + "error" + } } SentryTraced("github-$user") { @@ -133,12 +189,15 @@ fun Github( user = newText } ) - Text("Random repo $result") + Text("Random repo: $result") Button( onClick = { scope.launch { - result = + result = try { GithubAPI.service.listReposAsync(user.text, perPage).random().full_name + } catch (e: Throwable) { + "error" + } } }, modifier = Modifier diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 620acaa04cf..506f11f7ffb 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -143,10 +143,16 @@ android:text="@string/open_frame_data_for_spans"/>