diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adcc6ea87d7..ed624a35590 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -158,7 +158,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { - public static fun isForegroundImportance ()Z + public static fun isForegroundImportance (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)Z } public class io/sentry/android/core/CurrentActivityHolder { @@ -445,6 +445,7 @@ public class io/sentry/android/core/performance/AppStartMetrics { 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 setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V 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 1121a6bfe75..7e804038fa9 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 @@ -167,7 +167,8 @@ private void startTracing(final @NotNull Activity activity) { // we only track app start for processes that will show an Activity (full launch). // Here we check the process importance which will tell us that. - final boolean foregroundImportance = ContextUtils.isForegroundImportance(); + final boolean foregroundImportance = + ContextUtils.isForegroundImportance(activity, buildInfoProvider); if (foregroundImportance && appStartTimeSpan.hasStarted()) { appStartTime = appStartTimeSpan.getStartTimestamp(); coldStart = 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 2e76de4d123..ec77f5bdcf9 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 @@ -1,6 +1,5 @@ package io.sentry.android.core; -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; @@ -15,6 +14,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; +import android.os.PowerManager; import android.provider.Settings; import android.util.DisplayMetrics; import io.sentry.ILogger; @@ -26,6 +26,7 @@ import java.io.FileReader; import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -161,22 +162,77 @@ static String getVersionName(final @NotNull PackageInfo packageInfo) { return Integer.toString(packageInfo.versionCode); } + /* + * https://github.com/firebase/firebase-android-sdk/blob/58540de24c9b1eb7780c9f642c2cf17478e65734/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java#L497 + * + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ /** * Check if the Started process has IMPORTANCE_FOREGROUND importance which means that the process * will start an Activity. * * @return true if IMPORTANCE_FOREGROUND and false otherwise */ - @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 + @SuppressLint("NewApi") + @SuppressWarnings("deprecation") + public static boolean isForegroundImportance( + final @NotNull Context appContext, final @NotNull BuildInfoProvider buildInfoProvider) { + + // Do not call ProcessStats.getActivityManger, caching will break tests that indirectly depend + // on ProcessStats. + ActivityManager activityManager = + (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return true; + } + List appProcesses = + activityManager.getRunningAppProcesses(); + if (appProcesses != null) { + String appProcessName = appContext.getPackageName(); + String allowedAppProcessNamePrefix = appProcessName + ":"; + for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) { + if (appProcess.importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { + continue; + } + if (appProcess.processName.equals(appProcessName) + || appProcess.processName.startsWith(allowedAppProcessNamePrefix)) { + boolean isAppInForeground = true; + + // For the case when the app is in foreground and the device transitions to sleep mode, + // the importance of the process is set to IMPORTANCE_TOP_SLEEPING. However, this + // importance level was introduced in M. Pre M, the process importance is not changed to + // IMPORTANCE_TOP_SLEEPING when the display turns off. So we need to rely also on the + // state of the display to decide if any app process is really visible. + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) { + PowerManager powerManager = + (PowerManager) appContext.getSystemService(Context.POWER_SERVICE); + if (powerManager != null) { + isAppInForeground = + buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.KITKAT_WATCH + ? powerManager.isInteractive() + : powerManager.isScreenOn(); + } + } + + if (isAppInForeground) { + return true; + } + } + } } + return false; } 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 46590826ef1..1fee9efae0e 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 @@ -87,6 +87,7 @@ public static synchronized void init( @NotNull Sentry.OptionsConfiguration configuration) { try { + final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); Sentry.init( OptionsContainer.create(SentryAndroidOptions.class), options -> { @@ -103,7 +104,6 @@ public static synchronized void init( (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); - final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final LoadClass loadClass = new LoadClass(); final ActivityFramesTracker activityFramesTracker = new ActivityFramesTracker(loadClass, options); @@ -148,7 +148,8 @@ public static synchronized void init( true); final @NotNull IHub hub = Sentry.getCurrentHub(); - if (hub.getOptions().isEnableAutoSessionTracking() && ContextUtils.isForegroundImportance()) { + if (hub.getOptions().isEnableAutoSessionTracking() + && ContextUtils.isForegroundImportance(context, buildInfoProvider)) { // The LifecycleWatcher of AppLifecycleIntegration may already started a session // so only start a session if it's not already started // This e.g. happens on React Native, or e.g. on deferred SDK init 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 5c29e95b63b..fc5a5f81e8d 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 @@ -4,8 +4,11 @@ import android.content.ContentProvider; import android.os.SystemClock; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import io.sentry.ITransactionProfiler; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; import java.util.ArrayList; @@ -13,6 +16,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -102,6 +106,11 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } + @VisibleForTesting + public void setAppLaunchedInForeground(boolean appLaunchedInForeground) { + this.appLaunchedInForeground = appLaunchedInForeground; + } + /** * Provides all collected content provider onCreate time spans * @@ -137,12 +146,27 @@ public long getClassLoadedUptimeMs() { // Only started when sdk version is >= N final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); if (appStartSpan.hasStarted()) { - return appStartSpan; + return validateAppStartSpan(appStartSpan); } } // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return validateAppStartSpan(getSdkInitTimeSpan()); + } + + private @NotNull TimeSpan validateAppStartSpan(final @NotNull TimeSpan appStartSpan) { + long spanStartMillis = appStartSpan.getStartTimestampMs(); + long spanEndMillis = + appStartSpan.hasStopped() + ? appStartSpan.getProjectedStopTimestampMs() + : SystemClock.uptimeMillis(); + long durationMillis = spanEndMillis - spanStartMillis; + // If the app was launched more than 1 minute ago or it was launched in the background we return + // an empty span, as the app start will be wrong + if (durationMillis > TimeUnit.MINUTES.toMillis(1) || !isAppLaunchedInForeground()) { + return new TimeSpan(); + } + return appStartSpan; } @TestOnly @@ -195,7 +219,9 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.appLaunchedInForeground = ContextUtils.isForegroundImportance(); + instance.appLaunchedInForeground = + ContextUtils.isForegroundImportance( + application, new BuildInfoProvider(new AndroidLogger())); } } 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 f936b6251ce..897c4628c57 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 @@ -94,6 +94,7 @@ class ActivityLifecycleIntegrationTest { whenever(hub.options).thenReturn(options) + AppStartMetrics.getInstance().isAppLaunchedInForeground = true // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() 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 b758fae1f83..8c7e295c0e5 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 @@ -38,6 +38,7 @@ class ContextUtilsTest { private lateinit var shadowActivityManager: ShadowActivityManager private lateinit var context: Context private lateinit var logger: ILogger + private val buildInfoProvider = mock() @BeforeTest fun `set up`() { @@ -46,6 +47,7 @@ class ContextUtilsTest { ShadowBuild.reset() val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? shadowActivityManager = Shadow.extract(activityManager) + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.TIRAMISU) } @Test @@ -197,7 +199,7 @@ class ContextUtilsTest { @Test fun `returns true when app started with foreground importance`() { - assertTrue(ContextUtils.isForegroundImportance()) + assertTrue(ContextUtils.isForegroundImportance(context, buildInfoProvider)) } @Test @@ -211,6 +213,6 @@ class ContextUtilsTest { } ) ) - assertFalse(ContextUtils.isForegroundImportance()) + assertFalse(ContextUtils.isForegroundImportance(context, buildInfoProvider)) } } 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 4283326677a..ef99613f9cf 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 @@ -46,6 +46,7 @@ class PerformanceAndroidEventProcessorTest { tracesSampleRate: Double? = 1.0, enablePerformanceV2: Boolean = false ): PerformanceAndroidEventProcessor { + AppStartMetrics.getInstance().isAppLaunchedInForeground = true options.tracesSampleRate = tracesSampleRate options.isEnablePerformanceV2 = enablePerformanceV2 whenever(hub.options).thenReturn(options) 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 990c3f4b135..966a29a24e6 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 @@ -339,7 +339,7 @@ class SentryAndroidTest { val context = ContextUtilsTestHelper.createMockContext() Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> - mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } + mockedContextUtils.`when` { ContextUtils.isForegroundImportance(any(), any()) } .thenReturn(inForeground) SentryAndroid.init(context) { options -> options.release = "prod" 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 0885024b003..64a51bf457e 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 @@ -10,8 +10,10 @@ import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertSame @@ -28,6 +30,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @Test @@ -106,4 +109,69 @@ class AppStartMetricsTest { fun `class load time is set`() { assertNotEquals(0, AppStartMetrics.getInstance().classLoadedUptimeMs) } + + @Test + fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + assertTrue(appStartTimeSpan.hasStarted()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = false + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertFalse(timeSpan.hasStarted()) + } + + @Test + fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + assertTrue(appStartTimeSpan.hasStarted()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertFalse(timeSpan.hasStarted()) + } + + @Test + fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + appStartTimeSpan.stop() + appStartTimeSpan.setStartedAt(1) + appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 1) + assertTrue(appStartTimeSpan.hasStarted()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertTrue(timeSpan.hasStarted()) + assertSame(appStartTimeSpan, timeSpan) + } + + @Test + fun `if app start span is longer than 1 minute, appStartTimeSpanWithFallback returns an empty span`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + appStartTimeSpan.stop() + appStartTimeSpan.setStartedAt(1) + appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) + assertTrue(appStartTimeSpan.hasStarted()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertFalse(timeSpan.hasStarted()) + } }