diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index 5a1322fc9b6..8efd1c40187 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -43,6 +43,7 @@ import com.google.firebase.crashlytics.internal.persistence.FileStore; import com.google.firebase.crashlytics.internal.settings.Settings; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; +import com.google.firebase.sessions.api.CrashEventReceiver; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FilenameFilter; @@ -189,6 +190,11 @@ synchronized void handleUncaughtException( Logger.getLogger() .d("Handling uncaught " + "exception \"" + ex + "\" from thread " + thread.getName()); + // Notify the Firebase Sessions SDK that a fatal crash has occurred. + if (!isOnDemand) { + CrashEventReceiver.notifyCrashOccurred(); + } + // Capture the time that the crash occurs and close over it so that the time doesn't // reflect when we get around to executing the task later. final long timestampMillis = System.currentTimeMillis(); diff --git a/firebase-perf/firebase-perf.gradle b/firebase-perf/firebase-perf.gradle index c0fd6df6056..48bc21c7ffb 100644 --- a/firebase-perf/firebase-perf.gradle +++ b/firebase-perf/firebase-perf.gradle @@ -70,6 +70,7 @@ android { buildConfigField("String", "TRANSPORT_LOG_SRC", "String.valueOf(\"FIREPERF\")") buildConfigField("Boolean", "ENFORCE_DEFAULT_LOG_SRC", "Boolean.valueOf(false)") buildConfigField("String", "FIREPERF_VERSION_NAME", "String.valueOf(\"" + property("version") + "\")") + buildConfigField("Boolean", "ENFORCE_LEGACY_SESSIONS", "Boolean.valueOf(false)") if (project.hasProperty("fireperfBuildForAutopush")) { // This allows the SDK to be built for "Autopush" env when the mentioned flag @@ -77,6 +78,7 @@ android { // SDK or the Test App). buildConfigField("String", "TRANSPORT_LOG_SRC", "String.valueOf(\"FIREPERF_AUTOPUSH\")") buildConfigField("Boolean", "ENFORCE_DEFAULT_LOG_SRC", "Boolean.valueOf(true)") + buildConfigField("Boolean", "ENFORCE_LEGACY_SESSIONS", "Boolean.valueOf(true)") } minSdkVersion project.minSdkVersion @@ -118,7 +120,7 @@ dependencies { api("com.google.firebase:firebase-components:18.0.0") api("com.google.firebase:firebase-config:21.5.0") api("com.google.firebase:firebase-installations:17.2.0") - api("com.google.firebase:firebase-sessions:2.0.7") { + api(project(":firebase-sessions")) { exclude group: 'com.google.firebase', module: 'firebase-common' exclude group: 'com.google.firebase', module: 'firebase-common-ktx' exclude group: 'com.google.firebase', module: 'firebase-components' diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfRegistrar.java b/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfRegistrar.java index c01f035af1f..daffc2de81a 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfRegistrar.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfRegistrar.java @@ -30,6 +30,8 @@ import com.google.firebase.perf.injection.modules.FirebasePerformanceModule; import com.google.firebase.platforminfo.LibraryVersionComponent; import com.google.firebase.remoteconfig.RemoteConfigComponent; +import com.google.firebase.sessions.api.FirebaseSessionsDependencies; +import com.google.firebase.sessions.api.SessionSubscriber; import java.util.Arrays; import java.util.List; import java.util.concurrent.Executor; @@ -47,6 +49,11 @@ public class FirebasePerfRegistrar implements ComponentRegistrar { private static final String LIBRARY_NAME = "fire-perf"; private static final String EARLY_LIBRARY_NAME = "fire-perf-early"; + static { + // Add Firebase Performance as a dependency of Sessions when this class is loaded into memory. + FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.PERFORMANCE); + } + @Override @Keep public List> getComponents() { diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerformance.java b/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerformance.java index 40468566225..cce0389039b 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerformance.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerformance.java @@ -34,14 +34,18 @@ import com.google.firebase.perf.config.RemoteConfigManager; import com.google.firebase.perf.logging.AndroidLogger; import com.google.firebase.perf.logging.ConsoleUrlGenerator; +import com.google.firebase.perf.logging.FirebaseSessionsEnforcementCheck; import com.google.firebase.perf.metrics.HttpMetric; import com.google.firebase.perf.metrics.Trace; +import com.google.firebase.perf.session.FirebasePerformanceSessionSubscriber; import com.google.firebase.perf.session.SessionManager; import com.google.firebase.perf.transport.TransportManager; import com.google.firebase.perf.util.Constants; import com.google.firebase.perf.util.ImmutableBundle; import com.google.firebase.perf.util.Timer; import com.google.firebase.remoteconfig.RemoteConfigComponent; +import com.google.firebase.sessions.api.FirebaseSessionsDependencies; +import com.google.firebase.sessions.api.SessionSubscriber; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.net.URL; @@ -92,6 +96,8 @@ public class FirebasePerformance implements FirebasePerformanceAttributable { // once during initialization and cache it. private final ImmutableBundle mMetadataBundle; + private final SessionSubscriber sessionSubscriber; + /** Valid HttpMethods for manual network APIs */ @StringDef({ HttpMethod.GET, @@ -136,11 +142,6 @@ public static FirebasePerformance getInstance() { // to false if it's been force disabled or it is set to null if neither. @Nullable private Boolean mPerformanceCollectionForceEnabledState = null; - private final FirebaseApp firebaseApp; - private final Provider firebaseRemoteConfigProvider; - private final FirebaseInstallationsApi firebaseInstallationsApi; - private final Provider transportFactoryProvider; - /** * Constructs the FirebasePerformance class and allows injecting dependencies. * @@ -166,23 +167,19 @@ public static FirebasePerformance getInstance() { ConfigResolver configResolver, SessionManager sessionManager) { - this.firebaseApp = firebaseApp; - this.firebaseRemoteConfigProvider = firebaseRemoteConfigProvider; - this.firebaseInstallationsApi = firebaseInstallationsApi; - this.transportFactoryProvider = transportFactoryProvider; - if (firebaseApp == null) { this.mPerformanceCollectionForceEnabledState = false; this.configResolver = configResolver; this.mMetadataBundle = new ImmutableBundle(new Bundle()); + this.sessionSubscriber = new FirebasePerformanceSessionSubscriber(false); return; } + FirebaseSessionsEnforcementCheck.setEnforcement(BuildConfig.ENFORCE_LEGACY_SESSIONS); TransportManager.getInstance() .initialize(firebaseApp, firebaseInstallationsApi, transportFactoryProvider); Context appContext = firebaseApp.getApplicationContext(); - // TODO(b/110178816): Explore moving off of main thread. mMetadataBundle = extractMetadata(appContext); remoteConfigManager.setFirebaseRemoteConfigProvider(firebaseRemoteConfigProvider); @@ -192,6 +189,9 @@ public static FirebasePerformance getInstance() { sessionManager.setApplicationContext(appContext); mPerformanceCollectionForceEnabledState = configResolver.getIsPerformanceCollectionEnabled(); + sessionSubscriber = new FirebasePerformanceSessionSubscriber(isPerformanceCollectionEnabled()); + FirebaseSessionsDependencies.register(sessionSubscriber); + if (logger.isLogcatEnabled() && isPerformanceCollectionEnabled()) { logger.info( String.format( @@ -282,7 +282,7 @@ public synchronized void setPerformanceCollectionEnabled(@Nullable Boolean enabl return; } - if (configResolver.getIsPerformanceCollectionDeactivated()) { + if (Boolean.TRUE.equals(configResolver.getIsPerformanceCollectionDeactivated())) { logger.info("Firebase Performance is permanently disabled"); return; } @@ -466,4 +466,9 @@ private static ImmutableBundle extractMetadata(Context appContext) { Boolean getPerformanceCollectionForceEnabledState() { return mPerformanceCollectionForceEnabledState; } + + @VisibleForTesting + SessionSubscriber getSessionSubscriber() { + return sessionSubscriber; + } } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/logging/FirebaseSessionsEnforcementCheck.kt b/firebase-perf/src/main/java/com/google/firebase/perf/logging/FirebaseSessionsEnforcementCheck.kt new file mode 100644 index 00000000000..0abef6b0008 --- /dev/null +++ b/firebase-perf/src/main/java/com/google/firebase/perf/logging/FirebaseSessionsEnforcementCheck.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.perf.logging + +import com.google.firebase.perf.session.PerfSession +import com.google.firebase.perf.session.isLegacy + +class FirebaseSessionsEnforcementCheck { + companion object { + /** When enabled, failed preconditions will cause assertion errors for debugging. */ + @JvmStatic var enforcement: Boolean = false + private var logger: AndroidLogger = AndroidLogger.getInstance() + + @JvmStatic + fun checkSession(session: PerfSession, failureMessage: String) { + if (session.isLegacy()) { + logger.debug("legacy session ${session.sessionId()}: $failureMessage") + assert(!enforcement) { failureMessage } + } + } + } +} diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java index 7574f989d92..bec5f8df82f 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java @@ -14,6 +14,8 @@ package com.google.firebase.perf.metrics; +import static com.google.firebase.perf.util.AppProcessesProvider.getAppProcesses; + import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityManager; @@ -509,17 +511,10 @@ public static boolean isAnyAppProcessInForeground(Context appContext) { if (activityManager == null) { return true; } - List appProcesses = - activityManager.getRunningAppProcesses(); - if (appProcesses != null) { - String appProcessName = appContext.getPackageName(); - String allowedAppProcessNamePrefix = appProcessName + ":"; + List appProcesses = getAppProcesses(appContext); + if (!appProcesses.isEmpty()) { for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) { - if (appProcess.importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { - continue; - } - if (appProcess.processName.equals(appProcessName) - || appProcess.processName.startsWith(allowedAppProcessNamePrefix)) { + if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { boolean isAppInForeground = true; // For the case when the app is in foreground and the device transitions to sleep mode, diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilder.java b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilder.java index 1e04744d1b2..6afbb2a4de9 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilder.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilder.java @@ -224,7 +224,7 @@ public NetworkRequestMetricBuilder setCustomAttributes(Map attri * point depending upon the current {@link PerfSession} verbosity. * * @see GaugeManager#collectGaugeMetricOnce(Timer) - * @see PerfSession#isGaugeAndEventCollectionEnabled() + * @see PerfSession#isVerbose() */ public NetworkRequestMetricBuilder setRequestStartTimeMicros(long time) { SessionManager sessionManager = SessionManager.getInstance(); @@ -234,7 +234,7 @@ public NetworkRequestMetricBuilder setRequestStartTimeMicros(long time) { builder.setClientStartTimeUs(time); updateSession(perfSession); - if (perfSession.isGaugeAndEventCollectionEnabled()) { + if (perfSession.isVerbose()) { gaugeManager.collectGaugeMetricOnce(perfSession.getTimer()); } @@ -265,12 +265,12 @@ public long getTimeToResponseInitiatedMicros() { * point depending upon the current {@link PerfSession} Verbosity. * * @see GaugeManager#collectGaugeMetricOnce(Timer) - * @see PerfSession#isGaugeAndEventCollectionEnabled() + * @see PerfSession#isVerbose() */ public NetworkRequestMetricBuilder setTimeToResponseCompletedMicros(long time) { builder.setTimeToResponseCompletedUs(time); - if (SessionManager.getInstance().perfSession().isGaugeAndEventCollectionEnabled()) { + if (SessionManager.getInstance().perfSession().isVerbose()) { gaugeManager.collectGaugeMetricOnce(SessionManager.getInstance().perfSession().getTimer()); } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/Trace.java b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/Trace.java index 91e5f44b4a0..6e9cc6fa47a 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/metrics/Trace.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/metrics/Trace.java @@ -233,7 +233,7 @@ public void start() { updateSession(perfSession); - if (perfSession.isGaugeAndEventCollectionEnabled()) { + if (perfSession.isVerbose()) { gaugeManager.collectGaugeMetricOnce(perfSession.getTimer()); } } @@ -259,7 +259,7 @@ public void stop() { if (!name.isEmpty()) { transportManager.log(new TraceMetricBuilder(this).build(), getAppState()); - if (SessionManager.getInstance().perfSession().isGaugeAndEventCollectionEnabled()) { + if (SessionManager.getInstance().perfSession().isVerbose()) { gaugeManager.collectGaugeMetricOnce( SessionManager.getInstance().perfSession().getTimer()); } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/FirebasePerformanceSessionSubscriber.kt b/firebase-perf/src/main/java/com/google/firebase/perf/session/FirebasePerformanceSessionSubscriber.kt new file mode 100644 index 00000000000..71062422d51 --- /dev/null +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/FirebasePerformanceSessionSubscriber.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.perf.session + +import com.google.firebase.perf.logging.FirebaseSessionsEnforcementCheck +import com.google.firebase.sessions.api.SessionSubscriber + +class FirebasePerformanceSessionSubscriber(override val isDataCollectionEnabled: Boolean) : + SessionSubscriber { + + override val sessionSubscriberName: SessionSubscriber.Name = SessionSubscriber.Name.PERFORMANCE + + override fun onSessionChanged(sessionDetails: SessionSubscriber.SessionDetails) { + val currentPerfSession = SessionManager.getInstance().perfSession() + // TODO(b/394127311): Add logic to deal with app start gauges. + FirebaseSessionsEnforcementCheck.checkSession(currentPerfSession, "onSessionChanged") + + val updatedSession = PerfSession.createWithId(sessionDetails.sessionId) + SessionManager.getInstance().updatePerfSession(updatedSession) + } +} diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/FirebaseSessionsHelper.kt b/firebase-perf/src/main/java/com/google/firebase/perf/session/FirebaseSessionsHelper.kt new file mode 100644 index 00000000000..7ab9bbf6fee --- /dev/null +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/FirebaseSessionsHelper.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.perf.session + +import com.google.firebase.perf.util.Constants +import java.util.UUID + +/** Identifies whether the [PerfSession] is legacy or not. */ +fun PerfSession.isLegacy(): Boolean { + return this.sessionId().isLegacy() +} + +/** Identifies whether the string is from a legacy [PerfSession]. */ +fun String.isLegacy(): Boolean { + return this.startsWith(Constants.UNDEFINED_AQS_ID_PREFIX) +} + +/** Creates a valid session ID for [PerfSession] that can be predictably identified as legacy. */ +fun createLegacySessionId(): String { + val uuid = UUID.randomUUID().toString().replace("-", "") + return uuid.replaceRange( + 0, + Constants.UNDEFINED_AQS_ID_PREFIX.length, + Constants.UNDEFINED_AQS_ID_PREFIX + ) +} diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/PerfSession.java b/firebase-perf/src/main/java/com/google/firebase/perf/session/PerfSession.java index 160a4507560..94c2ad74a0d 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/session/PerfSession.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/PerfSession.java @@ -28,21 +28,19 @@ /** Details of a session including a unique Id and related information. */ public class PerfSession implements Parcelable { - - private final String sessionId; private final Timer creationTime; - + private final String sessionId; private boolean isGaugeAndEventCollectionEnabled = false; /* * Creates a PerfSession object and decides what metrics to collect. */ - public static PerfSession createWithId(@NonNull String sessionId) { - String prunedSessionId = sessionId.replace("-", ""); - PerfSession session = new PerfSession(prunedSessionId, new Clock()); - session.setGaugeAndEventCollectionEnabled(shouldCollectGaugesAndEvents()); - - return session; + public static PerfSession createWithId(@Nullable String aqsSessionId) { + String sessionId = aqsSessionId; + if (sessionId == null) { + sessionId = FirebaseSessionsHelperKt.createLegacySessionId(); + } + return new PerfSession(sessionId, new Clock()); } /** Creates a PerfSession with the provided {@code sessionId} and {@code clock}. */ @@ -50,6 +48,7 @@ public static PerfSession createWithId(@NonNull String sessionId) { public PerfSession(String sessionId, Clock clock) { this.sessionId = sessionId; creationTime = clock.getTime(); + isGaugeAndEventCollectionEnabled = shouldCollectGaugesAndEvents(); } private PerfSession(@NonNull Parcel in) { @@ -59,7 +58,8 @@ private PerfSession(@NonNull Parcel in) { creationTime = in.readParcelable(Timer.class.getClassLoader()); } - /** Returns the sessionId of the object. */ + /** Returns the sessionId for the given session. */ + @NonNull public String sessionId() { return sessionId; } @@ -71,37 +71,11 @@ public Timer getTimer() { return creationTime; } - /* - * Enables/Disables the gauge and event collection for the system. - */ - public void setGaugeAndEventCollectionEnabled(boolean enabled) { - isGaugeAndEventCollectionEnabled = enabled; - } - - /* - * Returns if gauge and event collection is enabled for the system. - */ - public boolean isGaugeAndEventCollectionEnabled() { - return isGaugeAndEventCollectionEnabled; - } - /** Returns if the current session is verbose or not. */ public boolean isVerbose() { return isGaugeAndEventCollectionEnabled; } - /** Checks if the current {@link com.google.firebase.perf.v1.PerfSession} is verbose or not. */ - @VisibleForTesting - static boolean isVerbose(@NonNull com.google.firebase.perf.v1.PerfSession perfSession) { - for (SessionVerbosity sessionVerbosity : perfSession.getSessionVerbosityList()) { - if (sessionVerbosity == SessionVerbosity.GAUGES_AND_SYSTEM_EVENTS) { - return true; - } - } - - return false; - } - /** * Checks if it has been more than {@link ConfigResolver#getSessionsMaxDurationMinutes()} time * since the creation time of the current session. @@ -163,11 +137,20 @@ public static com.google.firebase.perf.v1.PerfSession[] buildAndSort( } /** If true, Session Gauge collection is enabled. */ - public static boolean shouldCollectGaugesAndEvents() { + @VisibleForTesting + public boolean shouldCollectGaugesAndEvents() { ConfigResolver configResolver = ConfigResolver.getInstance(); - return configResolver.isPerformanceMonitoringEnabled() - && Math.random() < configResolver.getSessionsSamplingRate(); + && (Math.abs(this.sessionId.hashCode() % 100) + < configResolver.getSessionsSamplingRate() * 100); + } + + /* + * Enables/Disables whether the session is verbose or not. + */ + @VisibleForTesting + public void setGaugeAndEventCollectionEnabled(boolean enabled) { + isGaugeAndEventCollectionEnabled = enabled; } /** @@ -208,4 +191,16 @@ public PerfSession[] newArray(int size) { return new PerfSession[size]; } }; + + /** Checks if the current {@link com.google.firebase.perf.v1.PerfSession} is verbose or not. */ + @VisibleForTesting + static boolean isVerbose(@NonNull com.google.firebase.perf.v1.PerfSession perfSession) { + for (SessionVerbosity sessionVerbosity : perfSession.getSessionVerbosityList()) { + if (sessionVerbosity == SessionVerbosity.GAUGES_AND_SYSTEM_EVENTS) { + return true; + } + } + + return false; + } } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/SessionManager.java b/firebase-perf/src/main/java/com/google/firebase/perf/session/SessionManager.java index 79d034b9b0b..6fceb64d1e5 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/session/SessionManager.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/SessionManager.java @@ -18,34 +18,26 @@ import android.content.Context; import androidx.annotation.Keep; import androidx.annotation.VisibleForTesting; -import com.google.firebase.perf.application.AppStateMonitor; -import com.google.firebase.perf.application.AppStateUpdateHandler; +import com.google.firebase.perf.logging.FirebaseSessionsEnforcementCheck; import com.google.firebase.perf.session.gauges.GaugeManager; -import com.google.firebase.perf.v1.ApplicationProcessState; import com.google.firebase.perf.v1.GaugeMetadata; import com.google.firebase.perf.v1.GaugeMetric; import java.lang.ref.WeakReference; import java.util.HashSet; import java.util.Iterator; +import java.util.Objects; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; /** Session manager to generate sessionIDs and broadcast to the application. */ @Keep // Needed because of b/117526359. -public class SessionManager extends AppStateUpdateHandler { - +public class SessionManager { @SuppressLint("StaticFieldLeak") private static final SessionManager instance = new SessionManager(); private final GaugeManager gaugeManager; - private final AppStateMonitor appStateMonitor; private final Set> clients = new HashSet<>(); private PerfSession perfSession; - private Future syncInitFuture; /** Returns the singleton instance of SessionManager. */ public static SessionManager getInstance() { @@ -54,24 +46,20 @@ public static SessionManager getInstance() { /** Returns the currently active PerfSession. */ public final PerfSession perfSession() { + FirebaseSessionsEnforcementCheck.checkSession(perfSession, "PerfSession.perfSession()"); + return perfSession; } private SessionManager() { - // Generate a new sessionID for every cold start. - this( - GaugeManager.getInstance(), - PerfSession.createWithId(UUID.randomUUID().toString()), - AppStateMonitor.getInstance()); + // session should quickly updated by session subscriber. + this(GaugeManager.getInstance(), PerfSession.createWithId(null)); } @VisibleForTesting - public SessionManager( - GaugeManager gaugeManager, PerfSession perfSession, AppStateMonitor appStateMonitor) { + public SessionManager(GaugeManager gaugeManager, PerfSession perfSession) { this.gaugeManager = gaugeManager; this.perfSession = perfSession; - this.appStateMonitor = appStateMonitor; - registerForAppState(); } /** @@ -79,49 +67,7 @@ public SessionManager( * (currently that is before onResume finishes) to ensure gauge collection starts on time. */ public void setApplicationContext(final Context appContext) { - // Get PerfSession in main thread first, because it is possible that app changes fg/bg state - // which creates a new perfSession, before the following is executed in background thread - final PerfSession appStartSession = perfSession; - // TODO(b/258263016): Migrate to go/firebase-android-executors - @SuppressLint("ThreadPoolCreation") - ExecutorService executorService = Executors.newSingleThreadExecutor(); - syncInitFuture = - executorService.submit( - () -> { - gaugeManager.initializeGaugeMetadataManager(appContext); - if (appStartSession.isGaugeAndEventCollectionEnabled()) { - gaugeManager.logGaugeMetadata( - appStartSession.sessionId(), ApplicationProcessState.FOREGROUND); - } - }); - } - - @Override - public void onUpdateAppState(ApplicationProcessState newAppState) { - super.onUpdateAppState(newAppState); - - if (appStateMonitor.isColdStart()) { - // We want the Session to remain unchanged if this is a cold start of the app since we already - // update the PerfSession in FirebasePerfProvider#onAttachInfo(). - return; - } - - if (newAppState == ApplicationProcessState.FOREGROUND) { - // A new foregrounding of app will force a new sessionID generation. - PerfSession session = PerfSession.createWithId(UUID.randomUUID().toString()); - updatePerfSession(session); - } else { - // If the session is running for too long, generate a new session and collect gauges as - // necessary. - if (perfSession.isSessionRunningTooLong()) { - PerfSession session = PerfSession.createWithId(UUID.randomUUID().toString()); - updatePerfSession(session); - } else { - // For any other state change of the application, modify gauge collection state as - // necessary. - startOrStopCollectingGauges(newAppState); - } - } + gaugeManager.initializeGaugeMetadataManager(appContext); } /** @@ -130,6 +76,9 @@ public void onUpdateAppState(ApplicationProcessState newAppState) { * @see PerfSession#isSessionRunningTooLong() */ public void stopGaugeCollectionIfSessionRunningTooLong() { + FirebaseSessionsEnforcementCheck.checkSession( + perfSession, "SessionManager.stopGaugeCollectionIfSessionRunningTooLong"); + if (perfSession.isSessionRunningTooLong()) { gaugeManager.stopCollectingGauges(); } @@ -145,12 +94,14 @@ public void stopGaugeCollectionIfSessionRunningTooLong() { */ public void updatePerfSession(PerfSession perfSession) { // Do not update the perf session if it is the exact same sessionId. - if (perfSession.sessionId() == this.perfSession.sessionId()) { + if (Objects.equals(perfSession.sessionId(), this.perfSession.sessionId())) { return; } this.perfSession = perfSession; + // TODO(b/394127311): Update/verify behavior for Firebase Sessions. + synchronized (clients) { for (Iterator> i = clients.iterator(); i.hasNext(); ) { SessionAwareObject callback = i.next().get(); @@ -164,11 +115,11 @@ public void updatePerfSession(PerfSession perfSession) { } } - // Log the gauge metadata event if data collection is enabled. - logGaugeMetadataIfCollectionEnabled(appStateMonitor.getAppState()); + // Log gauge metadata. + logGaugeMetadataIfCollectionEnabled(); // Start of stop the gauge data collection. - startOrStopCollectingGauges(appStateMonitor.getAppState()); + startOrStopCollectingGauges(); } /** @@ -178,8 +129,7 @@ public void updatePerfSession(PerfSession perfSession) { * this does not reset the perfSession. */ public void initializeGaugeCollection() { - logGaugeMetadataIfCollectionEnabled(ApplicationProcessState.FOREGROUND); - startOrStopCollectingGauges(ApplicationProcessState.FOREGROUND); + startOrStopCollectingGauges(); } /** @@ -206,15 +156,19 @@ public void unregisterForSessionUpdates(WeakReference client } } - private void logGaugeMetadataIfCollectionEnabled(ApplicationProcessState appState) { - if (perfSession.isGaugeAndEventCollectionEnabled()) { - gaugeManager.logGaugeMetadata(perfSession.sessionId(), appState); + private void logGaugeMetadataIfCollectionEnabled() { + FirebaseSessionsEnforcementCheck.checkSession( + perfSession, "logGaugeMetadataIfCollectionEnabled"); + if (perfSession.isVerbose()) { + gaugeManager.logGaugeMetadata(perfSession.sessionId()); } } - private void startOrStopCollectingGauges(ApplicationProcessState appState) { - if (perfSession.isGaugeAndEventCollectionEnabled()) { - gaugeManager.startCollectingGauges(perfSession, appState); + private void startOrStopCollectingGauges() { + FirebaseSessionsEnforcementCheck.checkSession(perfSession, "startOrStopCollectingGauges"); + + if (perfSession.isVerbose()) { + gaugeManager.startCollectingGauges(perfSession); } else { gaugeManager.stopCollectingGauges(); } @@ -224,9 +178,4 @@ private void startOrStopCollectingGauges(ApplicationProcessState appState) { public void setPerfSession(PerfSession perfSession) { this.perfSession = perfSession; } - - @VisibleForTesting - public Future getSyncInitFuture() { - return this.syncInitFuture; - } } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/CpuGaugeCollector.java b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/CpuGaugeCollector.java index e33d363c0aa..528de2e62ae 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/CpuGaugeCollector.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/CpuGaugeCollector.java @@ -17,8 +17,6 @@ import static android.system.Os.sysconf; import android.annotation.SuppressLint; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.system.OsConstants; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -78,7 +76,7 @@ public class CpuGaugeCollector { private final String procFileName; private final long clockTicksPerSecond; - @Nullable private ScheduledFuture cpuMetricCollectorJob = null; + @Nullable private ScheduledFuture cpuMetricCollectorJob = null; private long cpuMetricCollectionRateMs = UNSET_CPU_METRIC_COLLECTION_RATE; // TODO(b/258263016): Migrate to go/firebase-android-executors @@ -163,11 +161,12 @@ private synchronized void scheduleCpuMetricCollectionWithRate( this.cpuMetricCollectionRateMs = cpuMetricCollectionRate; try { cpuMetricCollectorJob = - cpuMetricCollectorExecutor.scheduleAtFixedRate( + cpuMetricCollectorExecutor.scheduleWithFixedDelay( () -> { CpuMetricReading currCpuReading = syncCollectCpuMetric(referenceTime); if (currCpuReading != null) { cpuMetricReadings.add(currCpuReading); + GaugeCounter.incrementCounter(); } }, /* initialDelay */ 0, @@ -181,12 +180,13 @@ private synchronized void scheduleCpuMetricCollectionWithRate( private synchronized void scheduleCpuMetricCollectionOnce(Timer referenceTime) { try { @SuppressWarnings("FutureReturnValueIgnored") - ScheduledFuture unusedFuture = + ScheduledFuture unusedFuture = cpuMetricCollectorExecutor.schedule( () -> { CpuMetricReading currCpuReading = syncCollectCpuMetric(referenceTime); if (currCpuReading != null) { cpuMetricReadings.add(currCpuReading); + GaugeCounter.incrementCounter(); } }, /* initialDelay */ 0, @@ -227,12 +227,7 @@ private CpuMetricReading syncCollectCpuMetric(Timer referenceTime) { } private long getClockTicksPerSecond() { - if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { - return sysconf(OsConstants._SC_CLK_TCK); - } else { - // TODO(b/110779408): Figure out how to collect this info for Android API 20 and below. - return INVALID_SC_PER_CPU_CLOCK_TICK; - } + return sysconf(OsConstants._SC_CLK_TCK); } private long convertClockTicksToMicroseconds(long clockTicks) { diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeCounter.kt b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeCounter.kt new file mode 100644 index 00000000000..bb91258d73d --- /dev/null +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeCounter.kt @@ -0,0 +1,57 @@ +// Copyright 2025 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. + +package com.google.firebase.perf.session.gauges + +import androidx.annotation.VisibleForTesting +import java.util.concurrent.atomic.AtomicInteger + +/** + * [GaugeCounter] is a thread-safe counter for gauge metrics. If the metrics count reaches or + * exceeds [MAX_METRIC_COUNT], it attempts to log the metrics to Firelog. + */ +object GaugeCounter { + private const val MAX_METRIC_COUNT = 50 + // For debugging explore re-introducing logging. + private val counter = AtomicInteger(0) + + @set:VisibleForTesting(otherwise = VisibleForTesting.NONE) + @set:JvmStatic + var gaugeManager: GaugeManager = GaugeManager.getInstance() + + @JvmStatic + fun incrementCounter() { + val metricsCount = counter.incrementAndGet() + + if (metricsCount >= MAX_METRIC_COUNT) { + // TODO(b/394127311): There can be rare conditions where there's an attempt to log metrics + // even when it's currently logging them. While this is a no-op, it might be worth + // exploring optimizing it further to prevent additional calls to [GaugeManager]. + gaugeManager.logGaugeMetrics() + } + } + + @JvmStatic + fun decrementCounter() { + counter.decrementAndGet() + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @JvmStatic + fun resetCounter() { + counter.set(0) + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) @JvmStatic fun count(): Int = counter.get() +} diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeManager.java b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeManager.java index 7f6182a9c15..372c961257a 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeManager.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeManager.java @@ -20,6 +20,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.firebase.components.Lazy; +import com.google.firebase.perf.application.AppStateUpdateHandler; import com.google.firebase.perf.config.ConfigResolver; import com.google.firebase.perf.logging.AndroidLogger; import com.google.firebase.perf.session.PerfSession; @@ -31,7 +32,6 @@ import com.google.firebase.perf.v1.GaugeMetadata; import com.google.firebase.perf.v1.GaugeMetric; import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -41,7 +41,7 @@ * information and logging it to the Transport. */ @Keep // Needed because of b/117526359. -public class GaugeManager { +public class GaugeManager extends AppStateUpdateHandler { private static final AndroidLogger logger = AndroidLogger.getInstance(); private static final GaugeManager instance = new GaugeManager(); @@ -49,7 +49,6 @@ public class GaugeManager { // This is a guesstimate of the max amount of time to wait before any pending metrics' collection // might take. private static final long TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS = 20; - private static final long APPROX_NUMBER_OF_DATA_POINTS_PER_GAUGE_METRIC = 20; private static final long INVALID_GAUGE_COLLECTION_FREQUENCY = -1; private final Lazy gaugeManagerExecutor; @@ -59,8 +58,8 @@ public class GaugeManager { private final TransportManager transportManager; @Nullable private GaugeMetadataManager gaugeMetadataManager; - @Nullable private ScheduledFuture gaugeManagerDataCollectionJob = null; - @Nullable private String sessionId = null; + @Nullable private ScheduledFuture gaugeManagerDataCollectionJob = null; + @Nullable private PerfSession session = null; private ApplicationProcessState applicationProcessState = ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN; @@ -72,8 +71,8 @@ private GaugeManager() { TransportManager.getInstance(), ConfigResolver.getInstance(), null, - new Lazy<>(() -> new CpuGaugeCollector()), - new Lazy<>(() -> new MemoryGaugeCollector())); + new Lazy<>(CpuGaugeCollector::new), + new Lazy<>(MemoryGaugeCollector::new)); } @VisibleForTesting @@ -81,7 +80,7 @@ private GaugeManager() { Lazy gaugeManagerExecutor, TransportManager transportManager, ConfigResolver configResolver, - GaugeMetadataManager gaugeMetadataManager, + @Nullable GaugeMetadataManager gaugeMetadataManager, Lazy cpuGaugeCollector, Lazy memoryGaugeCollector) { @@ -91,6 +90,7 @@ private GaugeManager() { this.gaugeMetadataManager = gaugeMetadataManager; this.cpuGaugeCollector = cpuGaugeCollector; this.memoryGaugeCollector = memoryGaugeCollector; + registerForAppState(); } /** Initializes GaugeMetadataManager which requires application context. */ @@ -98,64 +98,69 @@ public void initializeGaugeMetadataManager(Context appContext) { this.gaugeMetadataManager = new GaugeMetadataManager(appContext); } + @Override + public void onUpdateAppState(ApplicationProcessState applicationProcessState) { + // If it isn't a verbose session (or unset) update the app state and return. + if (session == null || !session.isVerbose()) { + this.applicationProcessState = applicationProcessState; + return; + } + + // Log existing gauges to the current app state. + logGaugeMetrics(); + + // Update App State. + this.applicationProcessState = applicationProcessState; + + // Start collecting gauges for the new app state. + startCollectingGauges(this.applicationProcessState, session.getTimer()); + } + /** Returns the singleton instance of this class. */ public static synchronized GaugeManager getInstance() { return instance; } /** - * Starts the collection of available gauges for the given {@code sessionId} and {@code - * applicationProcessState}. The collected Gauge Metrics will be flushed at regular intervals. + * Starts the collection of available gauges for the given {@link PerfSession}. + * The collected Gauge Metrics will be flushed by {@link GaugeCounter} * *

GaugeManager can only collect gauges for one session at a time, and if this method is called * again with the same or new sessionId while it's already collecting gauges, all future gauges - * will then be associated with the same or new sessionId and applicationProcessState. + * will then be associated with the same or new sessionId. * * @param session The {@link PerfSession} to which the collected gauges will be associated with. - * @param applicationProcessState The {@link ApplicationProcessState} the collected GaugeMetrics - * will be associated with. * @note: This method is NOT thread safe - {@link this.startCollectingGauges()} and {@link - * this.stopCollectingGauges()} should always be called from the same thread. + * this.stopCollectingGauges()} should always be called from the same thread. */ - public void startCollectingGauges( - PerfSession session, ApplicationProcessState applicationProcessState) { - if (this.sessionId != null) { + public void startCollectingGauges(PerfSession session) { + if (this.session != null) { stopCollectingGauges(); } - long collectionFrequency = startCollectingGauges(applicationProcessState, session.getTimer()); + // TODO(b/394127311): Explore always setting the app state as FOREGROUND. + ApplicationProcessState gaugeCollectionApplicationProcessState = applicationProcessState; + if (gaugeCollectionApplicationProcessState + == ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN) { + logger.warn("Start collecting gauges with APPLICATION_PROCESS_STATE_UNKNOWN"); + // Since the application process state is unknown, collect gauges at the foreground frequency. + gaugeCollectionApplicationProcessState = ApplicationProcessState.FOREGROUND; + } + + long collectionFrequency = + startCollectingGauges(gaugeCollectionApplicationProcessState, session.getTimer()); if (collectionFrequency == INVALID_GAUGE_COLLECTION_FREQUENCY) { logger.warn("Invalid gauge collection frequency. Unable to start collecting Gauges."); return; } - this.sessionId = session.sessionId(); - this.applicationProcessState = applicationProcessState; - - // This is needed, otherwise the Runnable might use a stale value. - final String sessionIdForScheduledTask = sessionId; - final ApplicationProcessState applicationProcessStateForScheduledTask = applicationProcessState; - - try { - gaugeManagerDataCollectionJob = - gaugeManagerExecutor - .get() - .scheduleAtFixedRate( - () -> { - syncFlush(sessionIdForScheduledTask, applicationProcessStateForScheduledTask); - }, - /* initialDelay= */ collectionFrequency - * APPROX_NUMBER_OF_DATA_POINTS_PER_GAUGE_METRIC, - /* period= */ collectionFrequency * APPROX_NUMBER_OF_DATA_POINTS_PER_GAUGE_METRIC, - TimeUnit.MILLISECONDS); - - } catch (RejectedExecutionException e) { - logger.warn("Unable to start collecting Gauges: " + e.getMessage()); - } + this.session = session; } /** - * Starts the collection of available Gauges for the given {@code appState}. + * Starts the collection of available Gauges for the given {@code appState}. If it's being + * collected for a different app state, it stops that prior to starting it for the given + * {@code appState}. * * @param appState The app state to which the collected gauges are associated. * @param referenceTime The time off which the system time is calculated when collecting gauges. @@ -189,35 +194,51 @@ private long startCollectingGauges(ApplicationProcessState appState, Timer refer * this.stopCollectingGauges()} should always be called from the same thread. */ public void stopCollectingGauges() { - if (this.sessionId == null) { + if (session == null) { return; } - // This is needed, otherwise the Runnable might use a stale value. - final String sessionIdForScheduledTask = sessionId; - final ApplicationProcessState applicationProcessStateForScheduledTask = applicationProcessState; - cpuGaugeCollector.get().stopCollecting(); memoryGaugeCollector.get().stopCollecting(); - if (gaugeManagerDataCollectionJob != null) { - gaugeManagerDataCollectionJob.cancel(false); + logGaugeMetrics(); + this.session = null; + } + + /** + * Logs the existing GaugeMetrics to Firelog, associates it with the current {@link PerfSession} + * and {@link ApplicationProcessState}. + * + * @return true if a new data collection job is started, false otherwise. + */ + protected boolean logGaugeMetrics() { + if (session == null) { + logger.debug("Attempted to log Gauge Metrics when session was null."); + return false; + } + + // If there's an existing gaugeManagerDataCollectionJob, this is a no-op. + if (gaugeManagerDataCollectionJob != null && !gaugeManagerDataCollectionJob.isDone()) { + logger.debug("Attempted to start an additional gauge logging operation."); + return false; } - // Flush any data that was collected for this session one last time. - @SuppressWarnings("FutureReturnValueIgnored") - ScheduledFuture unusedFuture = + logExistingGaugeMetrics(session.sessionId(), applicationProcessState); + return true; + } + + private void logExistingGaugeMetrics( + String sessionId, ApplicationProcessState applicationProcessState) { + // Flush any data that was collected and attach it to the given session and app state. + gaugeManagerDataCollectionJob = gaugeManagerExecutor .get() .schedule( () -> { - syncFlush(sessionIdForScheduledTask, applicationProcessStateForScheduledTask); + syncFlush(sessionId, applicationProcessState); }, TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS, TimeUnit.MILLISECONDS); - - this.sessionId = null; - this.applicationProcessState = ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN; } /** @@ -225,7 +246,7 @@ public void stopCollectingGauges() { * proto and logs it to transport. * * @param sessionId The sessionId to which the collected GaugeMetrics should be associated with. - * @param appState The app state for which these gauges are collected. + * @param appState The app state for which these gauges are attributed to. */ private void syncFlush(String sessionId, ApplicationProcessState appState) { GaugeMetric.Builder gaugeMetricBuilder = GaugeMetric.newBuilder(); @@ -233,12 +254,14 @@ private void syncFlush(String sessionId, ApplicationProcessState appState) { // Adding CPU metric readings. while (!cpuGaugeCollector.get().cpuMetricReadings.isEmpty()) { gaugeMetricBuilder.addCpuMetricReadings(cpuGaugeCollector.get().cpuMetricReadings.poll()); + GaugeCounter.decrementCounter(); } // Adding Memory metric readings. while (!memoryGaugeCollector.get().memoryMetricReadings.isEmpty()) { gaugeMetricBuilder.addAndroidMemoryReadings( memoryGaugeCollector.get().memoryMetricReadings.poll()); + GaugeCounter.decrementCounter(); } // Adding Session ID info. @@ -250,19 +273,18 @@ private void syncFlush(String sessionId, ApplicationProcessState appState) { /** * Log the Gauge Metadata information to the transport. * - * @param sessionId The {@link PerfSession#sessionId()} to which the collected Gauge Metrics - * should be associated with. - * @param appState The {@link ApplicationProcessState} for which these gauges are collected. + * @param sessionId The {@link PerfSession#sessionId()} ()} to which the collected Gauge Metrics + * should be associated with. * @return true if GaugeMetadata was logged, false otherwise. */ - public boolean logGaugeMetadata(String sessionId, ApplicationProcessState appState) { + public boolean logGaugeMetadata(String sessionId) { if (gaugeMetadataManager != null) { GaugeMetric gaugeMetric = GaugeMetric.newBuilder() .setSessionId(sessionId) .setGaugeMetadata(getGaugeMetadata()) .build(); - transportManager.log(gaugeMetric, appState); + transportManager.log(gaugeMetric); return true; } return false; @@ -402,4 +424,9 @@ private long getMemoryGaugeCollectionFrequencyMs( return memoryGaugeCollectionFrequency; } } + + @VisibleForTesting + void setApplicationProcessState(ApplicationProcessState applicationProcessState) { + this.applicationProcessState = applicationProcessState; + } } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeMetadataManager.java b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeMetadataManager.java index 6b4466dfc35..ed38dd8f38d 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeMetadataManager.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/GaugeMetadataManager.java @@ -17,18 +17,11 @@ import android.app.ActivityManager; import android.app.ActivityManager.MemoryInfo; import android.content.Context; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import androidx.annotation.VisibleForTesting; import com.google.firebase.perf.logging.AndroidLogger; import com.google.firebase.perf.util.StorageUnit; import com.google.firebase.perf.util.Utils; import com.google.firebase.perf.v1.GaugeMetadata; -import java.io.BufferedReader; -import java.io.FileReader; -import java.io.IOException; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * The {@code GaugeMetadataManager} class is responsible for collecting {@link GaugeMetadata} @@ -41,7 +34,6 @@ class GaugeMetadataManager { private final Runtime runtime; private final ActivityManager activityManager; private final MemoryInfo memoryInfo; - private final Context appContext; GaugeMetadataManager(Context appContext) { this(Runtime.getRuntime(), appContext); @@ -50,7 +42,6 @@ class GaugeMetadataManager { @VisibleForTesting GaugeMetadataManager(Runtime runtime, Context appContext) { this.runtime = runtime; - this.appContext = appContext; this.activityManager = (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE); memoryInfo = new ActivityManager.MemoryInfo(); activityManager.getMemoryInfo(memoryInfo); @@ -75,29 +66,6 @@ public int getMaxEncouragedAppJavaHeapMemoryKb() { /** Returns the total memory (in kilobytes) accessible by the kernel (called the RAM size). */ public int getDeviceRamSizeKb() { - if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) { - return Utils.saturatedIntCast(StorageUnit.BYTES.toKilobytes(memoryInfo.totalMem)); - } - - return readTotalRAM(/* procFileName= */ "/proc/meminfo"); - } - - /** Returns the total ram size of the device (in kilobytes) by reading the "proc/meminfo" file. */ - @VisibleForTesting - int readTotalRAM(String procFileName) { - try (BufferedReader br = new BufferedReader(new FileReader(procFileName))) { - for (String s = br.readLine(); s != null; s = br.readLine()) { - if (s.startsWith("MemTotal")) { - Matcher m = Pattern.compile("\\d+").matcher(s); - return m.find() ? Integer.parseInt(m.group()) : 0; - } - } - } catch (IOException ioe) { - logger.warn("Unable to read '" + procFileName + "' file: " + ioe.getMessage()); - } catch (NumberFormatException nfe) { - logger.warn("Unable to parse '" + procFileName + "' file: " + nfe.getMessage()); - } - - return 0; + return Utils.saturatedIntCast(StorageUnit.BYTES.toKilobytes(memoryInfo.totalMem)); } } diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollector.java b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollector.java index eeaf4eb7c80..878353a2c5b 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollector.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollector.java @@ -50,7 +50,7 @@ public class MemoryGaugeCollector { public final ConcurrentLinkedQueue memoryMetricReadings; private final Runtime runtime; - @Nullable private ScheduledFuture memoryMetricCollectorJob = null; + @Nullable private ScheduledFuture memoryMetricCollectorJob = null; private long memoryMetricCollectionRateMs = UNSET_MEMORY_METRIC_COLLECTION_RATE; // TODO(b/258263016): Migrate to go/firebase-android-executors @@ -124,11 +124,12 @@ private synchronized void scheduleMemoryMetricCollectionWithRate( try { memoryMetricCollectorJob = - memoryMetricCollectorExecutor.scheduleAtFixedRate( + memoryMetricCollectorExecutor.scheduleWithFixedDelay( () -> { AndroidMemoryReading memoryReading = syncCollectMemoryMetric(referenceTime); if (memoryReading != null) { memoryMetricReadings.add(memoryReading); + GaugeCounter.incrementCounter(); } }, /* initialDelay */ 0, @@ -142,12 +143,13 @@ private synchronized void scheduleMemoryMetricCollectionWithRate( private synchronized void scheduleMemoryMetricCollectionOnce(Timer referenceTime) { try { @SuppressWarnings("FutureReturnValueIgnored") - ScheduledFuture unusedFuture = + ScheduledFuture unusedFuture = memoryMetricCollectorExecutor.schedule( () -> { AndroidMemoryReading memoryReading = syncCollectMemoryMetric(referenceTime); if (memoryReading != null) { memoryMetricReadings.add(memoryReading); + GaugeCounter.incrementCounter(); } }, /* initialDelay */ 0, diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/transport/TransportManager.java b/firebase-perf/src/main/java/com/google/firebase/perf/transport/TransportManager.java index 9600b099a6d..a38fb666c85 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/transport/TransportManager.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/transport/TransportManager.java @@ -14,6 +14,7 @@ package com.google.firebase.perf.transport; +import static com.google.firebase.perf.util.AppProcessesProvider.getProcessName; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MINUTES; @@ -230,6 +231,7 @@ private void finishInitialization() { applicationInfoBuilder = ApplicationInfo.newBuilder(); applicationInfoBuilder .setGoogleAppId(firebaseApp.getOptions().getApplicationId()) + .setProcessName(getProcessName(appContext)) .setAndroidAppInfo( AndroidApplicationInfo.newBuilder() .setPackageName(packageName) diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/util/AppProcessesProvider.kt b/firebase-perf/src/main/java/com/google/firebase/perf/util/AppProcessesProvider.kt new file mode 100644 index 00000000000..c4d820f31e3 --- /dev/null +++ b/firebase-perf/src/main/java/com/google/firebase/perf/util/AppProcessesProvider.kt @@ -0,0 +1,73 @@ +// Copyright 2025 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. + +package com.google.firebase.perf.util + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Process +import com.google.android.gms.common.util.ProcessUtils + +/** + * A singleton that contains helper functions to get relevant process details. TODO(b/418041083): + * Explore using a common utility. See [com.google.firebase.sessions.ProcessDetailsProvider]. + */ +object AppProcessesProvider { + /** Gets the details for all of this app's running processes. */ + @JvmStatic + fun getAppProcesses(context: Context): List { + val appUid = context.applicationInfo.uid + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val runningAppProcesses = activityManager?.runningAppProcesses ?: listOf() + + return runningAppProcesses.filterNotNull().filter { + // Only collect process info for this app's processes. + it.uid == appUid + } + } + + /** + * Gets this app's current process name. + * + * If the current process details are not found for whatever reason, returns an empty string. + */ + @JvmStatic + fun getProcessName(context: Context): String { + val pid = Process.myPid() + return getAppProcesses(context).find { it.pid == pid }?.processName ?: getProcessName() + } + + /** Gets the app's current process name. If it could not be found, return the default. */ + private fun getProcessName(default: String = ""): String { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { + return Process.myProcessName() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Application.getProcessName()?.let { + return it + } + } + + // GMS core has different ways to get the process name on old api levels. + ProcessUtils.getMyProcessName()?.let { + return it + } + + // Returns default if nothing works. + return default + } +} diff --git a/firebase-perf/src/main/java/com/google/firebase/perf/util/Constants.java b/firebase-perf/src/main/java/com/google/firebase/perf/util/Constants.java index f2704b903ce..42a126f014e 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/util/Constants.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/util/Constants.java @@ -22,6 +22,10 @@ public class Constants { public static final String PREFS_NAME = "FirebasePerfSharedPrefs"; public static final String ENABLE_DISABLE = "isEnabled"; + // A non-hex character guarantees it isn't an AQS generated UUID. + // https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.uuid/-uuid/ + public static final String UNDEFINED_AQS_ID_PREFIX = "z"; + public static final double MIN_SAMPLING_RATE = 0.0; public static final double MAX_SAMPLING_RATE = 1.0; diff --git a/firebase-perf/src/main/proto/firebase/perf/v1/perf_metric.proto b/firebase-perf/src/main/proto/firebase/perf/v1/perf_metric.proto index 8da196c0281..e10a1191671 100644 --- a/firebase-perf/src/main/proto/firebase/perf/v1/perf_metric.proto +++ b/firebase-perf/src/main/proto/firebase/perf/v1/perf_metric.proto @@ -292,7 +292,7 @@ message GaugeMetadata { // Additional metadata about an application and its state (including state of // the device at runtime) that is not provided by firebase data transport. // -// Next tag: 8 +// Next tag: 9 message ApplicationInfo { // Identifier for the application that has been registered with firebase. // Contains pantheon project number, platform and the hash of the (package @@ -316,6 +316,9 @@ message ApplicationInfo { // A map of global-level custom attribute names to values. map custom_attributes = 6; + + // The name of process that initiate the event. Currently only populated for Android apps. + optional string process_name = 8; } // Additional metadata about an android application that is not provided by diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerformanceTestBase.java b/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerformanceTestBase.java index b31696d963b..f1f258e518c 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerformanceTestBase.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerformanceTestBase.java @@ -25,9 +25,11 @@ import com.google.firebase.perf.config.ConfigResolver; import com.google.firebase.perf.session.PerfSession; import com.google.firebase.perf.session.SessionManager; +import com.google.firebase.perf.session.gauges.GaugeCounter; import com.google.firebase.perf.util.ImmutableBundle; import org.junit.After; import org.junit.Before; +import org.junit.BeforeClass; import org.robolectric.shadows.ShadowPackageManager; public class FirebasePerformanceTestBase { @@ -54,6 +56,12 @@ public class FirebasePerformanceTestBase { protected Context appContext; + @BeforeClass + public static void setUpBeforeClass() { + // TODO(b/394127311): Explore removing this. + GaugeCounter.resetCounter(); + } + @Before public void setUpFirebaseApp() { appContext = ApplicationProvider.getApplicationContext(); diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/application/AppStateMonitorTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/application/AppStateMonitorTest.java index 0b7d4bbfc17..f30ee5d73a0 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/application/AppStateMonitorTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/application/AppStateMonitorTest.java @@ -39,6 +39,8 @@ import com.google.firebase.perf.config.DeviceCacheManager; import com.google.firebase.perf.metrics.NetworkRequestMetricBuilder; import com.google.firebase.perf.metrics.Trace; +import com.google.firebase.perf.session.PerfSession; +import com.google.firebase.perf.session.SessionManager; import com.google.firebase.perf.session.gauges.GaugeManager; import com.google.firebase.perf.transport.TransportManager; import com.google.firebase.perf.util.Clock; @@ -80,6 +82,7 @@ public class AppStateMonitorTest extends FirebasePerformanceTestBase { @Before public void setUp() { currentTime = 0; + SessionManager.getInstance().updatePerfSession(PerfSession.createWithId("sessionId")); initMocks(this); doAnswer((Answer) invocationOnMock -> new Timer(currentTime)).when(clock).getTime(); diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilderTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilderTest.java index 61b3823741d..4a90184936c 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilderTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/metrics/NetworkRequestMetricBuilderTest.java @@ -242,7 +242,7 @@ public void testSessionIdNotAddedIfPerfSessionIsNull() { int numberOfSessionIds = metricBuilder.getSessions().size(); - new SessionManager(mock(GaugeManager.class), null, mock(AppStateMonitor.class)); + new SessionManager(mock(GaugeManager.class), null); assertThat(metricBuilder.getSessions()).hasSize(numberOfSessionIds); } diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/metrics/TraceTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/metrics/TraceTest.java index 0be443031f2..ca86a1a86b6 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/metrics/TraceTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/metrics/TraceTest.java @@ -1032,7 +1032,7 @@ public void testSessionIdNotAddedIfPerfSessionIsNull() { int numberOfSessionIds = trace.getSessions().size(); - new SessionManager(mock(GaugeManager.class), null, mock(AppStateMonitor.class)); + new SessionManager(mock(GaugeManager.class), null); assertThat(trace.getSessions()).hasSize(numberOfSessionIds); diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/FirebaseSessionsTestHelper.kt b/firebase-perf/src/test/java/com/google/firebase/perf/session/FirebaseSessionsTestHelper.kt new file mode 100644 index 00000000000..a617af94a58 --- /dev/null +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/FirebaseSessionsTestHelper.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.perf.session + +import com.google.firebase.perf.util.Clock + +fun createTestSession(suffix: Int): PerfSession { + // TODO(b/394127311): Add a method to verify legacy behavior. + // only hex characters and so it's AQS. + return PerfSession(testSessionId(suffix), Clock()) +} + +fun testSessionId(suffix: Int): String = "abc$suffix" diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/PerfSessionTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/session/PerfSessionTest.java index 43257987b0f..f7c8d400483 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/session/PerfSessionTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/PerfSessionTest.java @@ -15,6 +15,8 @@ package com.google.firebase.perf.session; import static com.google.common.truth.Truth.assertThat; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.createTestSession; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.testSessionId; import static com.google.firebase.perf.util.Constants.PREFS_NAME; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -62,12 +64,24 @@ public void setUp() { @Test public void instanceCreation() { - PerfSession session = new PerfSession("sessionId", mockClock); + PerfSession session = PerfSession.createWithId("sessionId"); assertThat(session).isNotNull(); session.setGaugeAndEventCollectionEnabled(true); - Assert.assertTrue(session.isGaugeAndEventCollectionEnabled()); + assertThat(session.isVerbose()).isTrue(); session.setGaugeAndEventCollectionEnabled(false); - Assert.assertFalse(session.isGaugeAndEventCollectionEnabled()); + assertThat(session.isVerbose()).isFalse(); + assertThat(FirebaseSessionsHelperKt.isLegacy(session)).isFalse(); + } + + @Test + public void legacyInstanceCreation() { + PerfSession perfSession = PerfSession.createWithId(null); + assertThat(perfSession).isNotNull(); + perfSession.setGaugeAndEventCollectionEnabled(true); + assertThat(perfSession.isVerbose()).isTrue(); + perfSession.setGaugeAndEventCollectionEnabled(false); + assertThat(perfSession.isVerbose()).isFalse(); + assertThat(FirebaseSessionsHelperKt.isLegacy(perfSession)).isTrue(); } @Test @@ -76,19 +90,20 @@ public void shouldCollectGaugesAndEvents_perfMonDisabledAtRuntime_sessionNotVerb Bundle bundle = new Bundle(); bundle.putFloat("sessions_sampling_percentage", 100); configResolver.setMetadataBundle(new ImmutableBundle(bundle)); + PerfSession testSession = PerfSession.createWithId("aqsSessionId"); // By default, session is verbose if developer has set 100% of session verbosity. - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isTrue(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isTrue(); // Case #1: developer has disabled Performance Monitoring during runtime. configResolver.setIsPerformanceCollectionEnabled(false); - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isFalse(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isFalse(); // Case #2: developer has enabled Performance Monitoring during runtime. configResolver.setIsPerformanceCollectionEnabled(true); - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isTrue(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isTrue(); } @Test @@ -99,20 +114,21 @@ public void shouldCollectGaugesAndEvents_perfMonDisabledAtBuildtime_verbosityDep bundle.putFloat("sessions_sampling_percentage", 100); bundle.putBoolean("firebase_performance_collection_enabled", false); configResolver.setMetadataBundle(new ImmutableBundle(bundle)); + PerfSession testSession = PerfSession.createWithId("aqsSessionId"); // By default, session is not verbose if developer disabled performance monitoring at build // time. - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isFalse(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isFalse(); // Case #1: developer has enabled Performance Monitoring during runtime. configResolver.setIsPerformanceCollectionEnabled(true); - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isTrue(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isTrue(); // Case #2: developer has disabled Performance Monitoring during runtime. configResolver.setIsPerformanceCollectionEnabled(false); - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isFalse(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isFalse(); } @Test @@ -122,24 +138,25 @@ public void shouldCollectGaugesAndEvents_perfMonDeactivated_sessionNotVerbose() bundle.putFloat("sessions_sampling_percentage", 100); bundle.putBoolean("firebase_performance_collection_deactivated", true); configResolver.setMetadataBundle(new ImmutableBundle(bundle)); + PerfSession testSession = PerfSession.createWithId("aqsSessionId"); // Session will never be verbose if developer deactivated performance monitoring at build time. - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isFalse(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isFalse(); // Case #1: developer has enabled Performance Monitoring during runtime. configResolver.setIsPerformanceCollectionEnabled(true); - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isFalse(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isFalse(); // Case #2: developer has disabled Performance Monitoring during runtime. configResolver.setIsPerformanceCollectionEnabled(false); - assertThat(PerfSession.shouldCollectGaugesAndEvents()).isFalse(); + assertThat(testSession.shouldCollectGaugesAndEvents()).isFalse(); } @Test public void testPerfSessionConversion() { - PerfSession session1 = new PerfSession("sessionId", mockClock); + PerfSession session1 = createTestSession(1); session1.setGaugeAndEventCollectionEnabled(true); com.google.firebase.perf.v1.PerfSession perfSession = session1.build(); @@ -150,7 +167,7 @@ public void testPerfSessionConversion() { @Test public void testPerfSessionConversionWithoutVerbosity() { - PerfSession session1 = new PerfSession("sessionId", mockClock); + PerfSession session1 = createTestSession(1); com.google.firebase.perf.v1.PerfSession perfSession = session1.build(); Assert.assertEquals(session1.sessionId(), perfSession.getSessionId()); @@ -160,22 +177,22 @@ public void testPerfSessionConversionWithoutVerbosity() { @Test public void testPerfSessionsCreateDisabledGaugeCollectionWhenVerboseSessionForceDisabled() { forceNonVerboseSession(); - PerfSession testPerfSession = PerfSession.createWithId("sessionId"); - assertThat(testPerfSession.isGaugeAndEventCollectionEnabled()).isFalse(); + PerfSession testPerfSession = createTestSession(1); + assertThat(testPerfSession.isVerbose()).isFalse(); } @Test public void testPerfSessionsCreateDisabledGaugeCollectionWhenSessionsFeatureDisabled() { forceSessionsFeatureDisabled(); - PerfSession testPerfSession = PerfSession.createWithId("sessionId"); - assertThat(testPerfSession.isGaugeAndEventCollectionEnabled()).isFalse(); + PerfSession testPerfSession = createTestSession(1); + assertThat(testPerfSession.isVerbose()).isFalse(); } @Test public void testPerfSessionsCreateEnablesGaugeCollectionWhenVerboseSessionForceEnabled() { forceVerboseSession(); - PerfSession testPerfSession = PerfSession.createWithId("sessionId"); - assertThat(testPerfSession.isGaugeAndEventCollectionEnabled()).isTrue(); + PerfSession testPerfSession = PerfSession.createWithId(testSessionId(1)); + assertThat(testPerfSession.isVerbose()).isTrue(); } @Test @@ -185,16 +202,16 @@ public void testBuildAndSortMovesTheVerboseSessionToTop() { // Next, create 3 non-verbose sessions List sessions = new ArrayList<>(); - sessions.add(PerfSession.createWithId("sessionId1")); - sessions.add(PerfSession.createWithId("sessionId2")); - sessions.add(PerfSession.createWithId("sessionId3")); + sessions.add(PerfSession.createWithId(testSessionId(1))); + sessions.add(PerfSession.createWithId(testSessionId(2))); + sessions.add(PerfSession.createWithId(testSessionId(3))); // Force all the sessions from now onwards to be verbose forceVerboseSession(); // Next, create 2 verbose sessions - sessions.add(PerfSession.createWithId("sessionId4")); - sessions.add(PerfSession.createWithId("sessionId5")); + sessions.add(PerfSession.createWithId(testSessionId(4))); + sessions.add(PerfSession.createWithId(testSessionId(5))); // Verify that the first session in the list of sessions was not verbose assertThat(sessions.get(0).isVerbose()).isFalse(); @@ -216,7 +233,7 @@ public void testIsExpiredReturnsFalseWhenCurrentSessionLengthIsLessThanMaxSessio - TimeUnit.MINUTES.toMicros(1)); // Default Max Session Length is 4 hours when(mockClock.getTime()).thenReturn(mockTimer); - PerfSession session = new PerfSession("sessionId", mockClock); + PerfSession session = new PerfSession(testSessionId(1), mockClock); assertThat(session.isSessionRunningTooLong()).isFalse(); } @@ -227,7 +244,7 @@ public void testIsExpiredReturnsFalseWhenCurrentSessionLengthIsEqualToMaxSession .thenReturn(TimeUnit.HOURS.toMicros(4)); // Default Max Session Length is 4 hours when(mockClock.getTime()).thenReturn(mockTimer); - PerfSession session = new PerfSession("sessionId", mockClock); + PerfSession session = new PerfSession(testSessionId(1), mockClock); assertThat(session.isSessionRunningTooLong()).isFalse(); } @@ -238,7 +255,7 @@ public void testIsExpiredReturnsTrueWhenCurrentSessionLengthIsGreaterThanMaxSess .thenReturn(TimeUnit.HOURS.toMicros(5)); // Default Max Session Length is 4 hours when(mockClock.getTime()).thenReturn(mockTimer); - PerfSession session = new PerfSession("sessionId", mockClock); + PerfSession session = new PerfSession(testSessionId(1), mockClock); assertThat(session.isSessionRunningTooLong()).isTrue(); } } diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/SessionManagerTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/session/SessionManagerTest.java index f3e3795f3f8..ab8cce7aab5 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/session/SessionManagerTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/SessionManagerTest.java @@ -15,10 +15,9 @@ package com.google.firebase.perf.session; import static com.google.common.truth.Truth.assertThat; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.createTestSession; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.testSessionId; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -40,7 +39,6 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.AdditionalMatchers; import org.mockito.ArgumentMatchers; import org.mockito.InOrder; import org.mockito.Mock; @@ -61,7 +59,7 @@ public class SessionManagerTest extends FirebasePerformanceTestBase { @Before public void setUp() { initMocks(this); - when(mockPerfSession.sessionId()).thenReturn("sessionId"); + when(mockPerfSession.sessionId()).thenReturn(testSessionId(5)); when(mockAppStateMonitor.isColdStart()).thenReturn(false); AppStateMonitor.getInstance().setIsColdStart(false); } @@ -70,139 +68,17 @@ public void setUp() { public void testInstanceCreation() { assertThat(SessionManager.getInstance()).isNotNull(); assertThat(SessionManager.getInstance()).isEqualTo(SessionManager.getInstance()); - assertThat(SessionManager.getInstance().perfSession().sessionId()).isNotNull(); } @Test - public void setApplicationContext_logGaugeMetadata_afterGaugeMetadataManagerIsInitialized() + public void setApplicationContext_initializeGaugeMetadataManager() throws ExecutionException, InterruptedException { - when(mockPerfSession.isGaugeAndEventCollectionEnabled()).thenReturn(true); + when(mockPerfSession.isVerbose()).thenReturn(true); InOrder inOrder = Mockito.inOrder(mockGaugeManager); - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, mockPerfSession); testSessionManager.setApplicationContext(mockApplicationContext); - testSessionManager.getSyncInitFuture().get(); inOrder.verify(mockGaugeManager).initializeGaugeMetadataManager(any()); - inOrder.verify(mockGaugeManager).logGaugeMetadata(any(), any()); - } - - @Test - public void testOnUpdateAppStateDoesNothingDuringAppStart() { - String oldSessionId = SessionManager.getInstance().perfSession().sessionId(); - - assertThat(oldSessionId).isNotNull(); - assertThat(oldSessionId).isEqualTo(SessionManager.getInstance().perfSession().sessionId()); - - AppStateMonitor.getInstance().setIsColdStart(true); - - SessionManager.getInstance().onUpdateAppState(ApplicationProcessState.FOREGROUND); - assertThat(oldSessionId).isEqualTo(SessionManager.getInstance().perfSession().sessionId()); - } - - @Test - public void testOnUpdateAppStateGeneratesNewSessionIdOnForegroundState() { - String oldSessionId = SessionManager.getInstance().perfSession().sessionId(); - - assertThat(oldSessionId).isNotNull(); - assertThat(oldSessionId).isEqualTo(SessionManager.getInstance().perfSession().sessionId()); - - SessionManager.getInstance().onUpdateAppState(ApplicationProcessState.FOREGROUND); - assertThat(oldSessionId).isNotEqualTo(SessionManager.getInstance().perfSession().sessionId()); - } - - @Test - public void testOnUpdateAppStateDoesntGenerateNewSessionIdOnBackgroundState() { - String oldSessionId = SessionManager.getInstance().perfSession().sessionId(); - - assertThat(oldSessionId).isNotNull(); - assertThat(oldSessionId).isEqualTo(SessionManager.getInstance().perfSession().sessionId()); - - SessionManager.getInstance().onUpdateAppState(ApplicationProcessState.BACKGROUND); - assertThat(oldSessionId).isEqualTo(SessionManager.getInstance().perfSession().sessionId()); - } - - @Test - public void testOnUpdateAppStateGeneratesNewSessionIdOnBackgroundStateIfPerfSessionExpires() { - when(mockPerfSession.isSessionRunningTooLong()).thenReturn(true); - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - String oldSessionId = testSessionManager.perfSession().sessionId(); - - assertThat(oldSessionId).isNotNull(); - assertThat(oldSessionId).isEqualTo(testSessionManager.perfSession().sessionId()); - - testSessionManager.onUpdateAppState(ApplicationProcessState.BACKGROUND); - assertThat(oldSessionId).isNotEqualTo(testSessionManager.perfSession().sessionId()); - } - - @Test - public void - testOnUpdateAppStateMakesGaugeManagerLogGaugeMetadataOnForegroundStateIfSessionIsVerbose() { - forceVerboseSession(); - - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - testSessionManager.onUpdateAppState(ApplicationProcessState.FOREGROUND); - - verify(mockGaugeManager) - .logGaugeMetadata( - anyString(), nullable(com.google.firebase.perf.v1.ApplicationProcessState.class)); - } - - @Test - public void - testOnUpdateAppStateDoesntMakeGaugeManagerLogGaugeMetadataOnForegroundStateIfSessionIsNonVerbose() { - forceNonVerboseSession(); - - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - testSessionManager.onUpdateAppState(ApplicationProcessState.FOREGROUND); - - verify(mockGaugeManager, never()) - .logGaugeMetadata( - anyString(), nullable(com.google.firebase.perf.v1.ApplicationProcessState.class)); - } - - @Test - public void - testOnUpdateAppStateDoesntMakeGaugeManagerLogGaugeMetadataOnBackgroundStateEvenIfSessionIsVerbose() { - forceVerboseSession(); - - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - testSessionManager.onUpdateAppState(ApplicationProcessState.BACKGROUND); - - verify(mockGaugeManager, never()) - .logGaugeMetadata( - anyString(), nullable(com.google.firebase.perf.v1.ApplicationProcessState.class)); - } - - @Test - public void - testOnUpdateAppStateMakesGaugeManagerLogGaugeMetadataOnBackgroundAppStateIfSessionIsVerboseAndTimedOut() { - when(mockPerfSession.isSessionRunningTooLong()).thenReturn(true); - forceVerboseSession(); - - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - testSessionManager.onUpdateAppState(ApplicationProcessState.BACKGROUND); - - verify(mockGaugeManager) - .logGaugeMetadata( - anyString(), nullable(com.google.firebase.perf.v1.ApplicationProcessState.class)); - } - - @Test - public void testOnUpdateAppStateMakesGaugeManagerStartCollectingGaugesIfSessionIsVerbose() { - forceVerboseSession(); - - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - testSessionManager.onUpdateAppState(ApplicationProcessState.FOREGROUND); - - verify(mockGaugeManager) - .startCollectingGauges(AdditionalMatchers.not(eq(mockPerfSession)), any()); } // LogGaugeData on new perf session when Verbose @@ -210,62 +86,32 @@ public void testOnUpdateAppStateMakesGaugeManagerStartCollectingGaugesIfSessionI // Mark Session as expired after time limit. @Test - public void testOnUpdateAppStateMakesGaugeManagerStopCollectingGaugesIfSessionIsNonVerbose() { + public void testUpdatePerfSessionMakesGaugeManagerStopCollectingGaugesIfSessionIsNonVerbose() { forceNonVerboseSession(); - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId")); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, mockPerfSession); + testSessionManager.updatePerfSession(createTestSession(1)); verify(mockGaugeManager).stopCollectingGauges(); } @Test - public void testOnUpdateAppStateMakesGaugeManagerStopCollectingGaugesWhenSessionsDisabled() { + public void testUpdatePerfSessionMakesGaugeManagerStopCollectingGaugesWhenSessionsDisabled() { forceSessionsFeatureDisabled(); - SessionManager testSessionManager = - new SessionManager( - mockGaugeManager, PerfSession.createWithId("testSessionId"), mockAppStateMonitor); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId2")); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, createTestSession(1)); + testSessionManager.updatePerfSession(createTestSession(2)); verify(mockGaugeManager).stopCollectingGauges(); } - @Test - public void testGaugeMetadataIsFlushedOnlyWhenNewVerboseSessionIsCreated() { - when(mockPerfSession.isSessionRunningTooLong()).thenReturn(false); - - // Start with a non verbose session - forceNonVerboseSession(); - SessionManager testSessionManager = - new SessionManager( - mockGaugeManager, PerfSession.createWithId("testSessionId1"), mockAppStateMonitor); - - verify(mockGaugeManager, times(0)) - .logGaugeMetadata( - eq("testSessionId1"), - eq(com.google.firebase.perf.v1.ApplicationProcessState.FOREGROUND)); - - // Forcing a verbose session will enable Gauge collection - forceVerboseSession(); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId2")); - verify(mockGaugeManager, times(1)).logGaugeMetadata(eq("testSessionId2"), any()); - - // Force a non-verbose session and verify if we are not logging metadata - forceVerboseSession(); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId3")); - verify(mockGaugeManager, times(1)).logGaugeMetadata(eq("testSessionId3"), any()); - } - @Test public void testSessionIdDoesNotUpdateIfPerfSessionRunsTooLong() { Timer mockTimer = mock(Timer.class); when(mockClock.getTime()).thenReturn(mockTimer); - PerfSession session = new PerfSession("sessionId", mockClock); - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, session, mockAppStateMonitor); + PerfSession session = new PerfSession(testSessionId(1), mockClock); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, session); assertThat(session.isSessionRunningTooLong()).isFalse(); @@ -273,37 +119,37 @@ public void testSessionIdDoesNotUpdateIfPerfSessionRunsTooLong() { .thenReturn(TimeUnit.HOURS.toMicros(5)); // Default Max Session Length is 4 hours assertThat(session.isSessionRunningTooLong()).isTrue(); - assertThat(testSessionManager.perfSession().sessionId()).isEqualTo("sessionId"); + assertThat(testSessionManager.perfSession().sessionId()).isEqualTo(testSessionId(1)); } @Test - public void testPerfSessionExpiredMakesGaugeManagerStopsCollectingGaugesIfSessionIsVerbose() { - forceVerboseSession(); + public void testUpdatePerfSessionStartsCollectingGaugesIfSessionIsVerbose() { Timer mockTimer = mock(Timer.class); when(mockClock.getTime()).thenReturn(mockTimer); + when(mockAppStateMonitor.getAppState()).thenReturn(ApplicationProcessState.FOREGROUND); - PerfSession session = new PerfSession("sessionId", mockClock); - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, session, mockAppStateMonitor); + PerfSession previousSession = createTestSession(1); + previousSession.setGaugeAndEventCollectionEnabled(false); - assertThat(session.isSessionRunningTooLong()).isFalse(); + PerfSession newSession = createTestSession(2); + newSession.setGaugeAndEventCollectionEnabled(true); - when(mockTimer.getDurationMicros()) - .thenReturn(TimeUnit.HOURS.toMicros(5)); // Default Max Session Length is 4 hours + SessionManager testSessionManager = new SessionManager(mockGaugeManager, previousSession); + testSessionManager.updatePerfSession(newSession); + testSessionManager.setApplicationContext(mockApplicationContext); - assertThat(session.isSessionRunningTooLong()).isTrue(); - verify(mockGaugeManager, times(0)).logGaugeMetadata(any(), any()); + verify(mockGaugeManager, times(1)).initializeGaugeMetadataManager(mockApplicationContext); + verify(mockGaugeManager, times(1)).startCollectingGauges(newSession); } @Test public void testPerfSession_sessionAwareObjects_doesntNotifyIfNotRegistered() { - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, mockPerfSession); FakeSessionAwareObject spySessionAwareObjectOne = spy(new FakeSessionAwareObject()); FakeSessionAwareObject spySessionAwareObjectTwo = spy(new FakeSessionAwareObject()); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId1")); + testSessionManager.updatePerfSession(createTestSession(1)); verify(spySessionAwareObjectOne, never()) .updateSession(ArgumentMatchers.nullable(PerfSession.class)); @@ -313,8 +159,7 @@ public void testPerfSession_sessionAwareObjects_doesntNotifyIfNotRegistered() { @Test public void testPerfSession_sessionAwareObjects_NotifiesIfRegistered() { - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, mockPerfSession); FakeSessionAwareObject spySessionAwareObjectOne = spy(new FakeSessionAwareObject()); FakeSessionAwareObject spySessionAwareObjectTwo = spy(new FakeSessionAwareObject()); @@ -322,8 +167,8 @@ public void testPerfSession_sessionAwareObjects_NotifiesIfRegistered() { testSessionManager.registerForSessionUpdates(new WeakReference<>(spySessionAwareObjectOne)); testSessionManager.registerForSessionUpdates(new WeakReference<>(spySessionAwareObjectTwo)); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId1")); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId2")); + testSessionManager.updatePerfSession(createTestSession(1)); + testSessionManager.updatePerfSession(createTestSession(2)); verify(spySessionAwareObjectOne, times(2)) .updateSession(ArgumentMatchers.nullable(PerfSession.class)); @@ -333,8 +178,7 @@ public void testPerfSession_sessionAwareObjects_NotifiesIfRegistered() { @Test public void testPerfSession_sessionAwareObjects_DoesNotNotifyIfUnregistered() { - SessionManager testSessionManager = - new SessionManager(mockGaugeManager, mockPerfSession, mockAppStateMonitor); + SessionManager testSessionManager = new SessionManager(mockGaugeManager, mockPerfSession); FakeSessionAwareObject spySessionAwareObjectOne = spy(new FakeSessionAwareObject()); FakeSessionAwareObject spySessionAwareObjectTwo = spy(new FakeSessionAwareObject()); @@ -347,11 +191,11 @@ public void testPerfSession_sessionAwareObjects_DoesNotNotifyIfUnregistered() { testSessionManager.registerForSessionUpdates(weakSpySessionAwareObjectOne); testSessionManager.registerForSessionUpdates(weakSpySessionAwareObjectTwo); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId1")); + testSessionManager.updatePerfSession(createTestSession(1)); testSessionManager.unregisterForSessionUpdates(weakSpySessionAwareObjectOne); testSessionManager.unregisterForSessionUpdates(weakSpySessionAwareObjectTwo); - testSessionManager.updatePerfSession(PerfSession.createWithId("testSessionId2")); + testSessionManager.updatePerfSession(createTestSession(2)); verify(spySessionAwareObjectOne, times(1)) .updateSession(ArgumentMatchers.nullable(PerfSession.class)); diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/CpuGaugeCollectorTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/CpuGaugeCollectorTest.java index 570e72e4a76..8d155196e3f 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/CpuGaugeCollectorTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/CpuGaugeCollectorTest.java @@ -60,9 +60,11 @@ public void tearDown() { @Test public void testStartCollectingAddsCpuMetricReadingsToTheConcurrentLinkedQueue() throws Exception { + int priorGaugeCount = GaugeCounter.count(); testGaugeCollector.startCollecting(100, new Timer()); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); assertThat(testGaugeCollector.cpuMetricReadings).hasSize(1); + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCount + 1); } @Test @@ -246,11 +248,17 @@ public void testCollectCpuMetricDoesntStartCollectingWithInvalidCpuMetricCollect @Test public void testCollectOnce_addOnlyOneCpuMetricReadingToQueue() { + int priorGaugeCount = GaugeCounter.count(); assertThat(testGaugeCollector.cpuMetricReadings).isEmpty(); testGaugeCollector.collectOnce(new Timer()); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + + // Simulate running an additional task. + fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + assertThat(testGaugeCollector.cpuMetricReadings).hasSize(1); + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCount + 1); } @Test diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeManagerTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeManagerTest.java index 5090d66c8b9..c111f5fb7a2 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeManagerTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeManagerTest.java @@ -15,6 +15,8 @@ package com.google.firebase.perf.session.gauges; import static com.google.common.truth.Truth.assertThat; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.createTestSession; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.testSessionId; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; @@ -25,14 +27,15 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; +import android.os.Looper; import androidx.test.core.app.ApplicationProvider; import com.google.firebase.components.Lazy; import com.google.firebase.perf.FirebasePerformanceTestBase; import com.google.firebase.perf.config.ConfigResolver; import com.google.firebase.perf.session.PerfSession; import com.google.firebase.perf.transport.TransportManager; -import com.google.firebase.perf.util.Clock; import com.google.firebase.perf.util.Timer; import com.google.firebase.perf.v1.AndroidMemoryReading; import com.google.firebase.perf.v1.ApplicationProcessState; @@ -40,7 +43,9 @@ import com.google.firebase.perf.v1.GaugeMetadata; import com.google.firebase.perf.v1.GaugeMetric; import com.google.testing.timing.FakeScheduledExecutorService; +import java.util.Random; import java.util.concurrent.TimeUnit; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -55,12 +60,14 @@ public final class GaugeManagerTest extends FirebasePerformanceTestBase { // This is a guesstimate of the max amount of time to wait before any pending metrics' collection // might take. private static final long TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS = 20; - private static final long APPROX_NUMBER_OF_DATA_POINTS_PER_GAUGE_METRIC = 20; private static final long DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_BG_MS = 100; private static final long DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_FG_MS = 50; private static final long DEFAULT_MEMORY_GAUGE_COLLECTION_FREQUENCY_BG_MS = 120; private static final long DEFAULT_MEMORY_GAUGE_COLLECTION_FREQUENCY_FG_MS = 60; + // See [com.google.firebase.perf.session.gauges.GaugeCounter]. + private static final long MAX_GAUGE_COUNTER_LIMIT = 50; + private GaugeManager testGaugeManager = null; private FakeScheduledExecutorService fakeScheduledExecutorService = null; private TransportManager mockTransportManager = null; @@ -122,24 +129,32 @@ public void setUp() { new Lazy<>(() -> fakeMemoryGaugeCollector)); } + @After + public void tearDown() { + shadowOf(Looper.getMainLooper()).idle(); + } + @Test - public void testStartCollectingGaugesStartsCollectingMetricsInBackgroundState() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.BACKGROUND); + public void testStartCollectingGaugesStartsCollectingMetricsDefault() { + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState( + ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN); + testGaugeManager.startCollectingGauges(fakeSession); verify(fakeCpuGaugeCollector) .startCollecting( - eq(DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_BG_MS), + eq(DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_FG_MS), ArgumentMatchers.nullable(Timer.class)); verify(fakeMemoryGaugeCollector) .startCollecting( - eq(DEFAULT_MEMORY_GAUGE_COLLECTION_FREQUENCY_BG_MS), + eq(DEFAULT_MEMORY_GAUGE_COLLECTION_FREQUENCY_FG_MS), ArgumentMatchers.nullable(Timer.class)); } @Test public void testStartCollectingGaugesStartsCollectingMetricsInForegroundState() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); verify(fakeCpuGaugeCollector) .startCollecting( eq(DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_FG_MS), @@ -150,25 +165,14 @@ public void testStartCollectingGaugesStartsCollectingMetricsInForegroundState() ArgumentMatchers.nullable(Timer.class)); } - @Test - public void - testStartCollectingGaugesDoesNotStartCollectingMetricsWithUnknownApplicationProcessState() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges( - fakeSession, ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN); - verify(fakeCpuGaugeCollector, never()) - .startCollecting(ArgumentMatchers.anyLong(), ArgumentMatchers.nullable(Timer.class)); - verify(fakeMemoryGaugeCollector, never()) - .startCollecting(ArgumentMatchers.anyLong(), ArgumentMatchers.nullable(Timer.class)); - } - @Test public void stopCollectingCPUMetric_invalidCPUCaptureFrequency_OtherMetricsWithValidFrequencyInBackground() { // PASS 1: Test with 0 doReturn(0L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyBackgroundMs(); - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.BACKGROUND); + PerfSession fakeSession1 = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.BACKGROUND); + testGaugeManager.startCollectingGauges(fakeSession1); // Verify that Cpu metric collection is not started verify(fakeCpuGaugeCollector, never()) @@ -180,8 +184,8 @@ public void testStartCollectingGaugesStartsCollectingMetricsInForegroundState() // PASS 2: Test with -ve value doReturn(-25L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyBackgroundMs(); - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.BACKGROUND); + PerfSession fakeSession2 = createTestSession(1); + testGaugeManager.startCollectingGauges(fakeSession2); // Verify that Cpu metric collection is not started verify(fakeCpuGaugeCollector, never()) @@ -197,8 +201,9 @@ public void testStartCollectingGaugesStartsCollectingMetricsInForegroundState() startCollectingGaugesOnBackground_invalidMemoryCaptureMs_onlyDisableMemoryCollection() { // PASS 1: Test with 0 doReturn(0L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyBackgroundMs(); - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.BACKGROUND); + PerfSession fakeSession1 = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.BACKGROUND); + testGaugeManager.startCollectingGauges(fakeSession1); // Verify that Memory metric collection is not started verify(fakeMemoryGaugeCollector, never()) @@ -210,8 +215,8 @@ public void testStartCollectingGaugesStartsCollectingMetricsInForegroundState() // PASS 2: Test with -ve value doReturn(-25L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyBackgroundMs(); - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.BACKGROUND); + PerfSession fakeSession2 = createTestSession(2); + testGaugeManager.startCollectingGauges(fakeSession2); // Verify that Memory metric collection is not started verify(fakeMemoryGaugeCollector, never()) @@ -226,8 +231,8 @@ public void testStartCollectingGaugesStartsCollectingMetricsInForegroundState() public void stopCollectingCPUMetric_invalidCPUCaptureFrequency_OtherMetricsWithValidFrequency() { // PASS 1: Test with 0 doReturn(0L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession1 = createTestSession(1); + testGaugeManager.startCollectingGauges(fakeSession1); // Verify that Cpu metric collection is not started verify(fakeCpuGaugeCollector, never()) @@ -239,8 +244,8 @@ public void stopCollectingCPUMetric_invalidCPUCaptureFrequency_OtherMetricsWithV // PASS 2: Test with -ve value doReturn(-25L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession2 = createTestSession(2); + testGaugeManager.startCollectingGauges(fakeSession2); // Verify that Cpu metric collection is not started verify(fakeCpuGaugeCollector, never()) @@ -256,8 +261,9 @@ public void stopCollectingCPUMetric_invalidCPUCaptureFrequency_OtherMetricsWithV startCollectingGaugesOnForeground_invalidMemoryCaptureMs_onlyDisableMemoryCollection() { // PASS 1: Test with 0 doReturn(0L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession1 = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession1); // Verify that Memory metric collection is not started verify(fakeMemoryGaugeCollector, never()) @@ -269,8 +275,8 @@ public void stopCollectingCPUMetric_invalidCPUCaptureFrequency_OtherMetricsWithV // PASS 2: Test with -ve value doReturn(-25L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession2 = createTestSession(2); + testGaugeManager.startCollectingGauges(fakeSession2); // Verify that Memory metric collection is not started verify(fakeMemoryGaugeCollector, never()) @@ -282,45 +288,29 @@ public void stopCollectingCPUMetric_invalidCPUCaptureFrequency_OtherMetricsWithV } @Test - public void testStartCollectingGaugesDoesNotStartAJobToConsumeMetricsWithUnknownAppState() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges( - fakeSession, ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN); + // TODO(b/394127311): Explore parametrized tests. + public void testStartCollectingGaugesDoesNotStartLogging_default() { + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState( + ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN); + testGaugeManager.startCollectingGauges(fakeSession); assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); } @Test - public void stopCollectingCPUMetrics_invalidCPUCaptureFrequency_appInForegrounf() { - // PASS 1: Test with 0 - doReturn(0L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); - - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.FOREGROUND); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); - - // PASS 2: Test with -ve value - doReturn(-25L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); - - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.FOREGROUND); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + public void testStartCollectingGaugesDoesNotStartLogging_appInForeground() { + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); } @Test - public void stopCollectingGauges_invalidMemoryCollectionFrequency_appInForeground() { - // PASS 1: Test with 0 - doReturn(0L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); - - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.FOREGROUND); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); - - // PASS 2: Test with -ve value - doReturn(-25L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); - - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.FOREGROUND); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + public void testStartCollectingGaugesDoesNotStartLogging_appInBackground() { + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.BACKGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); } @Test @@ -329,310 +319,276 @@ public void stopCollectingGauges_invalidGaugeCollectionFrequency_appInForeground doReturn(0L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); doReturn(0L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession1 = createTestSession(1); + testGaugeManager.startCollectingGauges(fakeSession1); assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); // PASS 2: Test with -ve value doReturn(-25L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); doReturn(-25L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); - PerfSession fakeSession2 = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.FOREGROUND); + PerfSession fakeSession2 = createTestSession(2); + testGaugeManager.startCollectingGauges(fakeSession2); assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); } @Test - public void startCollectingGauges_validGaugeCollectionFrequency_appInForeground() { - doReturn(25L).when(mockConfigResolver).getSessionsCpuCaptureFrequencyForegroundMs(); - doReturn(15L).when(mockConfigResolver).getSessionsMemoryCaptureFrequencyForegroundMs(); + public void testGaugeCounterStartsAJobToConsumeTheGeneratedMetrics() { + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + GaugeCounter.setGaugeManager(testGaugeManager); - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.FOREGROUND); + // There's no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); - assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) - .isEqualTo(15L * APPROX_NUMBER_OF_DATA_POINTS_PER_GAUGE_METRIC); - } + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT - 10); - @Test - public void testStartCollectingGaugesStartsAJobToConsumeTheGeneratedMetrics() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.BACKGROUND); + // There's still no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); + + generateMetricsAndIncrementCounter(10); assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) - .isEqualTo( - getMinimumBackgroundCollectionFrequency() - * APPROX_NUMBER_OF_DATA_POINTS_PER_GAUGE_METRIC); - - CpuMetricReading fakeCpuMetricReading1 = createFakeCpuMetricReading(200, 100); - CpuMetricReading fakeCpuMetricReading2 = createFakeCpuMetricReading(300, 200); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading1); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading2); - - AndroidMemoryReading fakeMemoryMetricReading1 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 123456); - AndroidMemoryReading fakeMemoryMetricReading2 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 23454678); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading1); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading2); + .isEqualTo(TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + + // Generate additional metrics, but doesn't start logging them as it hasn't met the threshold. + generateMetricsAndIncrementCounter(5); + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); + GaugeMetric recordedGaugeMetric = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); + getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric, fakeCpuMetricReading1, fakeCpuMetricReading2); + // It flushes all the original metrics in the ConcurrentLinkedQueues, but not the new ones + // added after the task completed. + int recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(MAX_GAUGE_COUNTER_LIMIT); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric, fakeMemoryMetricReading1, fakeMemoryMetricReading2); + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); } @Test - public void testStopCollectingGaugesStopsCollectingAllGaugeMetrics() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); + public void testGaugeCounterIsDecrementedWhenLogged() { + int priorGaugeCounter = GaugeCounter.count(); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.BACKGROUND); - verify(fakeCpuGaugeCollector) - .startCollecting(eq(DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_BG_MS), ArgumentMatchers.any()); + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + GaugeCounter.setGaugeManager(testGaugeManager); - testGaugeManager.stopCollectingGauges(); + // There's no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); - verify(fakeCpuGaugeCollector).stopCollecting(); - verify(fakeMemoryGaugeCollector).stopCollecting(); + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT - 10); + + // There's still no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); + + generateMetricsAndIncrementCounter(10); + + assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) + .isEqualTo(TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS); + + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCounter + MAX_GAUGE_COUNTER_LIMIT); + fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCounter); } @Test - public void testStopCollectingGaugesCreatesOneLastJobToConsumeAnyPendingMetrics() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.BACKGROUND); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + public void testDuplicateGaugeLoggingIsAvoided() { + int priorGaugeCounter = GaugeCounter.count(); + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + GaugeCounter.setGaugeManager(testGaugeManager); + + // There's no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); - testGaugeManager.stopCollectingGauges(); - assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT - 20); - CpuMetricReading fakeCpuMetricReading = createFakeCpuMetricReading(200, 100); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading); + // There's still no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); - AndroidMemoryReading fakeMemoryMetricReading = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 23454678); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading); + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT); + assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) .isEqualTo(TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); + GaugeMetric recordedGaugeMetric = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric, fakeCpuMetricReading); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric, fakeMemoryMetricReading); + getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND); + + // It flushes all the metrics in the ConcurrentLinkedQueues that were added. + int recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(2 * MAX_GAUGE_COUNTER_LIMIT - 20); + + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCounter); } @Test - public void testGaugeManagerClearsTheQueueEachRun() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); + public void testUpdateAppStateHandlesMultipleAppStates() { + PerfSession fakeSession = createTestSession(1); + fakeSession.setGaugeAndEventCollectionEnabled(true); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + GaugeCounter.setGaugeManager(testGaugeManager); - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.BACKGROUND); + // Generate metrics that don't exceed the GaugeCounter.MAX_COUNT. + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT - 10); - fakeCpuGaugeCollector.cpuMetricReadings.add(createFakeCpuMetricReading(200, 100)); - fakeCpuGaugeCollector.cpuMetricReadings.add(createFakeCpuMetricReading(300, 400)); - fakeMemoryGaugeCollector.memoryMetricReadings.add( - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 1234)); + // There's no job to log the gauges. + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); + + testGaugeManager.onUpdateAppState(ApplicationProcessState.BACKGROUND); - assertThat(fakeCpuGaugeCollector.cpuMetricReadings).isNotEmpty(); - assertThat(fakeMemoryGaugeCollector.memoryMetricReadings).isNotEmpty(); + assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) + .isEqualTo(TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - assertThat(fakeCpuGaugeCollector.cpuMetricReadings).isEmpty(); - assertThat(fakeMemoryGaugeCollector.memoryMetricReadings).isEmpty(); + shadowOf(Looper.getMainLooper()).idle(); + + // Generate additional metrics in the new app state. + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT + 1); + + GaugeMetric recordedGaugeMetric = + getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND); - fakeCpuGaugeCollector.cpuMetricReadings.add(createFakeCpuMetricReading(200, 100)); - fakeMemoryGaugeCollector.memoryMetricReadings.add( - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 1234)); - fakeMemoryGaugeCollector.memoryMetricReadings.add( - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 2345)); + // It flushes all metrics in the ConcurrentLinkedQueues. + int recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(MAX_GAUGE_COUNTER_LIMIT - 10); - assertThat(fakeCpuGaugeCollector.cpuMetricReadings).isNotEmpty(); - assertThat(fakeMemoryGaugeCollector.memoryMetricReadings).isNotEmpty(); + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); + // Simulate gauges collected in the new app state. fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - assertThat(fakeCpuGaugeCollector.cpuMetricReadings).isEmpty(); - assertThat(fakeMemoryGaugeCollector.memoryMetricReadings).isEmpty(); + shadowOf(Looper.getMainLooper()).idle(); + + recordedGaugeMetric = getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND); + + // Verify the metrics in the new app state. + recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(MAX_GAUGE_COUNTER_LIMIT + 1); + + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); } @Test - public void testStartingGaugeManagerWithNewSessionIdButSameAppState() { - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); + public void testGaugeManagerHandlesMultipleSessionIds() { + PerfSession fakeSession = createTestSession(1); + fakeSession.setGaugeAndEventCollectionEnabled(true); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.BACKGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + GaugeCounter.setGaugeManager(testGaugeManager); - // Start collecting Gauges. - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.BACKGROUND); - CpuMetricReading fakeCpuMetricReading1 = createFakeCpuMetricReading(200, 100); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading1); - AndroidMemoryReading fakeMemoryMetricReading1 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 1234); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading1); + // Generate metrics that don't exceed the GaugeCounter.MAX_COUNT. + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT - 10); + + PerfSession updatedPerfSession = createTestSession(2); + updatedPerfSession.setGaugeAndEventCollectionEnabled(true); + + // A new session and updated app state. + testGaugeManager.startCollectingGauges(updatedPerfSession); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + + assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) + .isEqualTo(TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric1 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric1, fakeCpuMetricReading1); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric1, fakeMemoryMetricReading1); - - // One Cpu and Memory metric was added when the gauge was collecting for the previous sessionId. - CpuMetricReading fakeCpuMetricReading2 = createFakeCpuMetricReading(400, 500); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading2); - AndroidMemoryReading fakeMemoryMetricReading2 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 2345); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading2); - - PerfSession fakeSession2 = new PerfSession("sessionId2", new Clock()); - - // Start collecting gauges for new session, but same app state. - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.BACKGROUND); - - // The next sweep conducted by GaugeManager still associates metrics to old sessionId and state. - fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric2 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric2, fakeCpuMetricReading2); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric2, fakeMemoryMetricReading2); - - // Collect some more Cpu and Memory metrics and verify that they're associated with new - // sessionId and state. - CpuMetricReading fakeCpuMetricReading3 = createFakeCpuMetricReading(500, 600); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading3); - AndroidMemoryReading fakeMemoryMetricReading3 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 3456); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading3); + shadowOf(Looper.getMainLooper()).idle(); + + // Generate metrics for the new session. + generateMetricsAndIncrementCounter(MAX_GAUGE_COUNTER_LIMIT + 1); + + GaugeMetric recordedGaugeMetric = + getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND); + + // It flushes all metrics in the ConcurrentLinkedQueues. + int recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(MAX_GAUGE_COUNTER_LIMIT - 10); + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); + + // Simulate gauges collected in the new app state. fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric3 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId2", recordedGaugeMetric3, fakeCpuMetricReading3); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId2", recordedGaugeMetric3, fakeMemoryMetricReading3); + shadowOf(Looper.getMainLooper()).idle(); + + recordedGaugeMetric = getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND); + + // Verify the metrics in the new app state. + recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(MAX_GAUGE_COUNTER_LIMIT + 1); + + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(2)); } @Test - public void testStartGaugeManagerWithSameSessionIdButDifferentAppState() { - PerfSession fakeSession = new PerfSession("sessionId", new Clock()); + public void testStopCollectingGaugesStopsCollectingAllGaugeMetrics() { + PerfSession fakeSession = createTestSession(1); - // Start collecting Gauges. - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.BACKGROUND); - CpuMetricReading fakeCpuMetricReading1 = createFakeCpuMetricReading(200, 100); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading1); - AndroidMemoryReading fakeMemoryMetricReading1 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 1234); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading1); + testGaugeManager.startCollectingGauges(fakeSession); + verify(fakeCpuGaugeCollector) + .startCollecting(eq(DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_FG_MS), ArgumentMatchers.any()); + verify(fakeMemoryGaugeCollector) + .startCollecting( + eq(DEFAULT_MEMORY_GAUGE_COLLECTION_FREQUENCY_FG_MS), ArgumentMatchers.any()); - fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric1 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric1, fakeCpuMetricReading1); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric1, fakeMemoryMetricReading1); - - // One Cpu and Memory metric was added when the gauge was collecting for the previous sessionId. - CpuMetricReading fakeCpuMetricReading2 = createFakeCpuMetricReading(400, 500); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading2); - AndroidMemoryReading fakeMemoryMetricReading2 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 2345); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading2); - - // Start collecting gauges for same session, but new app state - testGaugeManager.startCollectingGauges(fakeSession, ApplicationProcessState.FOREGROUND); - - // The next sweep conducted by GaugeManager still associates metrics to old sessionId and state. - fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric2 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric2, fakeCpuMetricReading2); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric2, fakeMemoryMetricReading2); - - // Collect some more Cpu and Memory metrics and verify that they're associated with new - // sessionId and state. - CpuMetricReading fakeCpuMetricReading3 = createFakeCpuMetricReading(500, 600); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading3); - AndroidMemoryReading fakeMemoryMetricReading3 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 3456); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading3); + testGaugeManager.stopCollectingGauges(); - fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric3 = - getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric3, fakeCpuMetricReading3); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric3, fakeMemoryMetricReading3); + verify(fakeCpuGaugeCollector).stopCollecting(); + verify(fakeMemoryGaugeCollector).stopCollecting(); } @Test - public void testStartGaugeManagerWithNewSessionIdAndNewAppState() { - PerfSession fakeSession1 = new PerfSession("sessionId", new Clock()); + public void testStopCollectingGaugesCreatesOneLastJobToConsumeAnyPendingMetrics() { + PerfSession fakeSession = createTestSession(1); + testGaugeManager.setApplicationProcessState(ApplicationProcessState.FOREGROUND); + testGaugeManager.startCollectingGauges(fakeSession); + assertThat(fakeScheduledExecutorService.isEmpty()).isTrue(); - // Start collecting Gauges. - testGaugeManager.startCollectingGauges(fakeSession1, ApplicationProcessState.BACKGROUND); - CpuMetricReading fakeCpuMetricReading1 = createFakeCpuMetricReading(200, 100); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading1); - AndroidMemoryReading fakeMemoryMetricReading1 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 1234); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading1); + generateMetricsAndIncrementCounter(2); - fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric1 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric1, fakeCpuMetricReading1); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric1, fakeMemoryMetricReading1); - - // One Cpu and Memory metric was added when the gauge was collecting for the previous sessionId. - CpuMetricReading fakeCpuMetricReading2 = createFakeCpuMetricReading(400, 500); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading2); - AndroidMemoryReading fakeMemoryMetricReading2 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 2345); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading2); - - PerfSession fakeSession2 = new PerfSession("sessionId2", new Clock()); - - // Start collecting gauges for new session and new app state - testGaugeManager.startCollectingGauges(fakeSession2, ApplicationProcessState.FOREGROUND); - - // The next sweep conducted by GaugeManager still associates metrics to old sessionId and state. - fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric2 = - getLastRecordedGaugeMetric(ApplicationProcessState.BACKGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric2, fakeCpuMetricReading2); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId", recordedGaugeMetric2, fakeMemoryMetricReading2); - - // Collect some more Cpu and Memory metrics and verify that they're associated with new - // sessionId and state. - CpuMetricReading fakeCpuMetricReading3 = createFakeCpuMetricReading(500, 600); - fakeCpuGaugeCollector.cpuMetricReadings.add(fakeCpuMetricReading3); - AndroidMemoryReading fakeMemoryMetricReading3 = - createFakeAndroidMetricReading(/* currentUsedAppJavaHeapMemoryKb= */ 3456); - fakeMemoryGaugeCollector.memoryMetricReadings.add(fakeMemoryMetricReading3); + testGaugeManager.stopCollectingGauges(); + assertThat(fakeScheduledExecutorService.isEmpty()).isFalse(); + + assertThat(fakeScheduledExecutorService.getDelayToNextTask(TimeUnit.MILLISECONDS)) + .isEqualTo(TIME_TO_WAIT_BEFORE_FLUSHING_GAUGES_QUEUE_MS); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); - GaugeMetric recordedGaugeMetric3 = - getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND, 1); - assertThatCpuGaugeMetricWasSentToTransport( - "sessionId2", recordedGaugeMetric3, fakeCpuMetricReading3); - assertThatMemoryGaugeMetricWasSentToTransport( - "sessionId2", recordedGaugeMetric3, fakeMemoryMetricReading3); + + GaugeMetric recordedGaugeMetric = + getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND); + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); + int recordedGaugeMetricsCount = + recordedGaugeMetric.getAndroidMemoryReadingsCount() + + recordedGaugeMetric.getCpuMetricReadingsCount(); + assertThat(recordedGaugeMetricsCount).isEqualTo(2); + + // TODO(b/394127311): Investigate why this isn't 0 on local runs. + // assertThat(GaugeCounter.INSTANCE.count()).isEqualTo(0); } @Test @@ -641,13 +597,13 @@ public void testLogGaugeMetadataSendDataToTransport() { when(fakeGaugeMetadataManager.getMaxAppJavaHeapMemoryKb()).thenReturn(1000); when(fakeGaugeMetadataManager.getMaxEncouragedAppJavaHeapMemoryKb()).thenReturn(800); - testGaugeManager.logGaugeMetadata("sessionId", ApplicationProcessState.FOREGROUND); + testGaugeManager.logGaugeMetadata(testSessionId(1)); GaugeMetric recordedGaugeMetric = - getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND, 1); + getLastRecordedGaugeMetric(ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN); GaugeMetadata recordedGaugeMetadata = recordedGaugeMetric.getGaugeMetadata(); - assertThat(recordedGaugeMetric.getSessionId()).isEqualTo("sessionId"); + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); assertThat(recordedGaugeMetadata.getDeviceRamSizeKb()) .isEqualTo(fakeGaugeMetadataManager.getDeviceRamSizeKb()); @@ -658,8 +614,7 @@ public void testLogGaugeMetadataSendDataToTransport() { } @Test - public void testLogGaugeMetadataDoesntLogWhenGaugeMetadataManagerNotAvailable() { - + public void testLogGaugeMetadataDoesNotLogWhenGaugeMetadataManagerNotAvailable() { testGaugeManager = new GaugeManager( new Lazy<>(() -> fakeScheduledExecutorService), @@ -669,8 +624,7 @@ public void testLogGaugeMetadataDoesntLogWhenGaugeMetadataManagerNotAvailable() new Lazy<>(() -> fakeCpuGaugeCollector), new Lazy<>(() -> fakeMemoryGaugeCollector)); - assertThat(testGaugeManager.logGaugeMetadata("sessionId", ApplicationProcessState.FOREGROUND)) - .isFalse(); + assertThat(testGaugeManager.logGaugeMetadata(testSessionId(1))).isFalse(); } @Test @@ -685,18 +639,17 @@ public void testLogGaugeMetadataLogsAfterApplicationContextIsSet() { new Lazy<>(() -> fakeCpuGaugeCollector), new Lazy<>(() -> fakeMemoryGaugeCollector)); - assertThat(testGaugeManager.logGaugeMetadata("sessionId", ApplicationProcessState.FOREGROUND)) - .isFalse(); + assertThat(testGaugeManager.logGaugeMetadata(testSessionId(1))).isFalse(); testGaugeManager.initializeGaugeMetadataManager(ApplicationProvider.getApplicationContext()); - assertThat(testGaugeManager.logGaugeMetadata("sessionId", ApplicationProcessState.FOREGROUND)) - .isTrue(); + assertThat(testGaugeManager.logGaugeMetadata(testSessionId(1))).isTrue(); GaugeMetric recordedGaugeMetric = - getLastRecordedGaugeMetric(ApplicationProcessState.FOREGROUND, 1); + getLastRecordedGaugeMetric(ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN); GaugeMetadata recordedGaugeMetadata = recordedGaugeMetric.getGaugeMetadata(); - assertThat(recordedGaugeMetric.getSessionId()).isEqualTo("sessionId"); + assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(testSessionId(1)); + assertThat(recordedGaugeMetadata).isNotEqualTo(GaugeMetadata.getDefaultInstance()); } @Test @@ -716,6 +669,22 @@ private long getMinimumBackgroundCollectionFrequency() { return DEFAULT_CPU_GAUGE_COLLECTION_FREQUENCY_BG_MS; } + // Simulates the behavior of Cpu and Memory Gauge collector. + private void generateMetricsAndIncrementCounter(long count) { + // TODO(b/394127311): Explore actually collecting metrics using the fake Cpu and Memory + // metric collectors. + Random random = new Random(); + for (int i = 0; i < count; ++i) { + if (random.nextInt(2) == 0) { + fakeCpuGaugeCollector.cpuMetricReadings.add(createFakeCpuMetricReading(100, 200)); + GaugeCounter.incrementCounter(); + } else { + fakeMemoryGaugeCollector.memoryMetricReadings.add(createFakeAndroidMetricReading(100)); + GaugeCounter.incrementCounter(); + } + } + } + private CpuMetricReading createFakeCpuMetricReading(long userTimeUs, long systemTimeUs) { CpuMetricReading.Builder fakeMetricReadingBuilder = CpuMetricReading.newBuilder(); fakeMetricReadingBuilder.setClientTimeUs(System.currentTimeMillis()); @@ -735,35 +704,26 @@ private AndroidMemoryReading createFakeAndroidMetricReading(int currentUsedAppJa * Gets the last recorded GaugeMetric, and verifies that they were logged for the right {@link * ApplicationProcessState}. * - * @param applicationProcessState The expected {@link ApplicationProcessState} that it was logged - * to. - * @param timesLogged Number of {@link GaugeMetric} that were expected to be logged to Transport. + * @param expectedApplicationProcessState The expected {@link ApplicationProcessState} that it was logged + * to. * @return The last logged {@link GaugeMetric}. */ private GaugeMetric getLastRecordedGaugeMetric( - ApplicationProcessState applicationProcessState, int timesLogged) { + ApplicationProcessState expectedApplicationProcessState) { ArgumentCaptor argMetric = ArgumentCaptor.forClass(GaugeMetric.class); - verify(mockTransportManager, times(timesLogged)) - .log(argMetric.capture(), eq(applicationProcessState)); + + // TODO(b/394127311): Revisit transportManager.log method which is only being called in unit + // tests. + if (expectedApplicationProcessState + == ApplicationProcessState.APPLICATION_PROCESS_STATE_UNKNOWN) { + verify(mockTransportManager, times(1)).log(argMetric.capture()); + } else { + verify(mockTransportManager, times(1)) + .log(argMetric.capture(), eq(expectedApplicationProcessState)); + } reset(mockTransportManager); // Required after resetting the mock. By default we assume that Transport is initialized. when(mockTransportManager.isInitialized()).thenReturn(true); return argMetric.getValue(); } - - private void assertThatCpuGaugeMetricWasSentToTransport( - String sessionId, GaugeMetric recordedGaugeMetric, CpuMetricReading... cpuMetricReadings) { - assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(sessionId); - assertThat(recordedGaugeMetric.getCpuMetricReadingsList()) - .containsAtLeastElementsIn(cpuMetricReadings); - } - - private void assertThatMemoryGaugeMetricWasSentToTransport( - String sessionId, - GaugeMetric recordedGaugeMetric, - AndroidMemoryReading... androidMetricReadings) { - assertThat(recordedGaugeMetric.getSessionId()).isEqualTo(sessionId); - assertThat(recordedGaugeMetric.getAndroidMemoryReadingsList()) - .containsAtLeastElementsIn(androidMetricReadings); - } } diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeMetadataManagerTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeMetadataManagerTest.java index 292747121dd..1592f77e5ad 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeMetadataManagerTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/GaugeMetadataManagerTest.java @@ -15,26 +15,20 @@ package com.google.firebase.perf.session.gauges; import static com.google.common.truth.Truth.assertThat; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.mockito.MockitoAnnotations.initMocks; import static org.robolectric.Shadows.shadowOf; import android.app.ActivityManager; import android.content.Context; -import android.os.Environment; import androidx.test.core.app.ApplicationProvider; import com.google.firebase.perf.FirebasePerformanceTestBase; import com.google.firebase.perf.util.StorageUnit; -import java.io.File; import java.io.IOException; -import java.io.Writer; -import java.nio.file.Files; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.robolectric.RobolectricTestRunner; -import org.robolectric.shadows.ShadowEnvironment; /** Unit tests for {@link com.google.firebase.perf.session.gauges.GaugeMetadataManager} */ @RunWith(RobolectricTestRunner.class) @@ -49,12 +43,11 @@ public class GaugeMetadataManagerTest extends FirebasePerformanceTestBase { @Mock private Runtime runtime; private ActivityManager activityManager; - private Context appContext; @Before public void setUp() { initMocks(this); - appContext = ApplicationProvider.getApplicationContext(); + Context appContext = ApplicationProvider.getApplicationContext(); activityManager = (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE); mockMemory(); @@ -90,62 +83,5 @@ public void testGetDeviceRamSize_returnsExpectedValue() throws IOException { int ramSize = testGaugeMetadataManager.getDeviceRamSizeKb(); assertThat(ramSize).isEqualTo(StorageUnit.BYTES.toKilobytes(DEVICE_RAM_SIZE_BYTES)); - assertThat(ramSize).isEqualTo(testGaugeMetadataManager.readTotalRAM(createFakeMemInfoFile())); } - - /** @return The file path of this fake file which can be used to read the file. */ - private String createFakeMemInfoFile() throws IOException { - // Due to file permission issues on forge, it's easiest to just write this file to the emulated - // robolectric external storage. - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED); - - File file = new File(Environment.getExternalStorageDirectory(), "FakeProcMemInfoFile"); - Writer fileWriter; - - fileWriter = Files.newBufferedWriter(file.toPath(), UTF_8); - fileWriter.write(MEM_INFO_CONTENTS); - fileWriter.close(); - - return file.getAbsolutePath(); - } - - private static final String MEM_INFO_CONTENTS = - "MemTotal: " - + DEVICE_RAM_SIZE_KB - + " kB\n" - + "MemFree: 542404 kB\n" - + "MemAvailable: 1392324 kB\n" - + "Buffers: 64292 kB\n" - + "Cached: 826180 kB\n" - + "SwapCached: 4196 kB\n" - + "Active: 934768 kB\n" - + "Inactive: 743812 kB\n" - + "Active(anon): 582132 kB\n" - + "Inactive(anon): 241500 kB\n" - + "Active(file): 352636 kB\n" - + "Inactive(file): 502312 kB\n" - + "Unevictable: 5148 kB\n" - + "Mlocked: 256 kB\n" - + "SwapTotal: 524284 kB\n" - + "SwapFree: 484800 kB\n" - + "Dirty: 4 kB\n" - + "Writeback: 0 kB\n" - + "AnonPages: 789404 kB\n" - + "Mapped: 241928 kB\n" - + "Shmem: 30632 kB\n" - + "Slab: 122320 kB\n" - + "SReclaimable: 42552 kB\n" - + "SUnreclaim: 79768 kB\n" - + "KernelStack: 22816 kB\n" - + "PageTables: 35344 kB\n" - + "NFS_Unstable: 0 kB\n" - + "Bounce: 0 kB\n" - + "WritebackTmp: 0 kB\n" - + "CommitLimit: 2042280 kB\n" - + "Committed_AS: 76623352 kB\n" - + "VmallocTotal: 251658176 kB\n" - + "VmallocUsed: 232060 kB\n" - + "VmallocChunk: 251347444 kB\n" - + "NvMapMemFree: 48640 kB\n" - + "NvMapMemUsed: 471460 kB\n"; } diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollectorTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollectorTest.java index a99be17bee3..0c4769e207c 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollectorTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/session/gauges/MemoryGaugeCollectorTest.java @@ -59,9 +59,11 @@ private void mockMemory() { @Test public void testStartCollecting_addsMemoryMetricReadingsToQueue() { + int priorGaugeCount = GaugeCounter.count(); testGaugeCollector.startCollecting(/* memoryMetricCollectionRateMs= */ 100, new Timer()); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); assertThat(testGaugeCollector.memoryMetricReadings).hasSize(1); + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCount + 1); } @Test @@ -148,11 +150,17 @@ public void testCollectedMemoryMetric_containsApproximatelyCorrectTimestamp() { @Test public void testCollectOnce_addOnlyOneMemoryMetricReadingToQueue() { + int priorGaugeCount = GaugeCounter.count(); assertThat(testGaugeCollector.memoryMetricReadings).isEmpty(); testGaugeCollector.collectOnce(new Timer()); fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + + // Simulate running an additional task. + fakeScheduledExecutorService.simulateSleepExecutingAtMostOneTask(); + assertThat(testGaugeCollector.memoryMetricReadings).hasSize(1); + assertThat(GaugeCounter.count()).isEqualTo(priorGaugeCount + 1); } @Test diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/transport/TransportManagerTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/transport/TransportManagerTest.java index 5376265aa0e..07b50c4377d 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/transport/TransportManagerTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/transport/TransportManagerTest.java @@ -15,6 +15,8 @@ package com.google.firebase.perf.transport; import static com.google.common.truth.Truth.assertThat; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.createTestSession; +import static com.google.firebase.perf.session.FirebaseSessionsTestHelperKt.testSessionId; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; @@ -28,6 +30,7 @@ import android.content.Context; import android.content.pm.PackageInfo; +import android.os.Process; import androidx.test.core.app.ApplicationProvider; import com.google.android.datatransport.TransportFactory; import com.google.android.gms.tasks.Tasks; @@ -40,7 +43,6 @@ import com.google.firebase.perf.config.ConfigResolver; import com.google.firebase.perf.session.SessionManager; import com.google.firebase.perf.shadows.ShadowPreconditions; -import com.google.firebase.perf.util.Clock; import com.google.firebase.perf.util.Constants; import com.google.firebase.perf.util.Constants.CounterNames; import com.google.firebase.perf.v1.AndroidMemoryReading; @@ -1168,8 +1170,7 @@ public void logGaugeMetric_globalCustomAttributesAreNotAdded() { public void logTraceMetric_sessionEnabled_doesNotStripOffSessionId() { TraceMetric.Builder validTrace = createValidTraceMetric().toBuilder(); List perfSessions = new ArrayList<>(); - perfSessions.add( - new com.google.firebase.perf.session.PerfSession("fakeSessionId", new Clock()).build()); + perfSessions.add(createTestSession(1).build()); validTrace.addAllPerfSessions(perfSessions); testTransportManager.log(validTrace.build()); @@ -1178,7 +1179,7 @@ public void logTraceMetric_sessionEnabled_doesNotStripOffSessionId() { PerfMetric loggedPerfMetric = getLastLoggedEvent(times(1)); assertThat(loggedPerfMetric.getTraceMetric().getPerfSessionsCount()).isEqualTo(1); assertThat(loggedPerfMetric.getTraceMetric().getPerfSessions(0).getSessionId()) - .isEqualTo("fakeSessionId"); + .isEqualTo(testSessionId(1)); } @Test @@ -1186,8 +1187,7 @@ public void logNetworkMetric_sessionEnabled_doesNotStripOffSessionId() { NetworkRequestMetric.Builder validNetworkRequest = createValidNetworkRequestMetric().toBuilder(); List perfSessions = new ArrayList<>(); - perfSessions.add( - new com.google.firebase.perf.session.PerfSession("fakeSessionId", new Clock()).build()); + perfSessions.add(createTestSession(1).build()); validNetworkRequest.clearPerfSessions().addAllPerfSessions(perfSessions); testTransportManager.log(validNetworkRequest.build()); @@ -1196,7 +1196,7 @@ public void logNetworkMetric_sessionEnabled_doesNotStripOffSessionId() { PerfMetric loggedPerfMetric = getLastLoggedEvent(times(1)); assertThat(loggedPerfMetric.getNetworkRequestMetric().getPerfSessionsCount()).isEqualTo(1); assertThat(loggedPerfMetric.getNetworkRequestMetric().getPerfSessions(0).getSessionId()) - .isEqualTo("fakeSessionId"); + .isEqualTo(testSessionId(1)); } @Test @@ -1400,6 +1400,11 @@ private void initializeTransport(boolean shouldInitialize) { if (shouldInitialize) { // Set the version name since Firebase sessions needs it. Context context = ApplicationProvider.getApplicationContext(); + + // For unit test, app context does not application info related to uid, so we have to force + // set it through process info. + context.getApplicationInfo().uid = Process.myUid(); + ShadowPackageManager shadowPackageManager = shadowOf(context.getPackageManager()); PackageInfo packageInfo = @@ -1469,6 +1474,8 @@ private static void validateApplicationInfo( .isEqualTo(FAKE_FIREBASE_APPLICATION_ID); assertThat(loggedPerfMetric.getApplicationInfo().getApplicationProcessState()) .isEqualTo(applicationProcessState); + assertThat(loggedPerfMetric.getApplicationInfo().getProcessName()) + .isEqualTo("com.google.firebase.perf.test"); assertThat(loggedPerfMetric.getApplicationInfo().hasAndroidAppInfo()).isTrue(); } diff --git a/firebase-sessions/CHANGELOG.md b/firebase-sessions/CHANGELOG.md index 70ad76eb6fe..7b9be5d2acd 100644 --- a/firebase-sessions/CHANGELOG.md +++ b/firebase-sessions/CHANGELOG.md @@ -1,36 +1,19 @@ # Unreleased - +* [changed] Use multi-process DataStore instead of Preferences DataStore +* [changed] Update the heuristic to detect cold app starts # 2.1.1 * [unchanged] Updated to keep SDK versions aligned. - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.1.0 * [changed] Add warning for known issue b/328687152 * [changed] Use Dagger for dependency injection * [changed] Updated datastore dependency to v1.1.3 to fix [CVE-2024-7254](https://github.com/advisories/GHSA-735f-pc8j-v9w8). - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.0.9 * [fixed] Make AQS resilient to background init in multi-process apps. - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.0.7 * [fixed] Removed extraneous logs that risk leaking internal identifiers. diff --git a/firebase-sessions/api.txt b/firebase-sessions/api.txt index 5824e4febdf..58174fd4849 100644 --- a/firebase-sessions/api.txt +++ b/firebase-sessions/api.txt @@ -1,6 +1,11 @@ // Signature format: 3.0 package com.google.firebase.sessions.api { + public final class CrashEventReceiver { + method public static void notifyCrashOccurred(); + field public static final com.google.firebase.sessions.api.CrashEventReceiver INSTANCE; + } + public final class FirebaseSessionsDependencies { method public static void addDependency(com.google.firebase.sessions.api.SessionSubscriber.Name subscriberName); method public static void register(com.google.firebase.sessions.api.SessionSubscriber subscriber); diff --git a/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt b/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt index fac6f1a4977..2d517a231e4 100644 --- a/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt +++ b/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt @@ -20,6 +20,8 @@ import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.StartupTimingMetric import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.FileInputStream import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -31,7 +33,7 @@ class StartupBenchmark { @Test fun startup() = benchmarkRule.measureRepeated( - packageName = "com.google.firebase.testing.sessions", + packageName = PACKAGE_NAME, metrics = listOf(StartupTimingMetric()), iterations = 5, startupMode = StartupMode.COLD, @@ -39,4 +41,35 @@ class StartupBenchmark { pressHome() startActivityAndWait() } + + @Test + fun startup_clearAppData() = + benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + iterations = 5, + startupMode = StartupMode.COLD, + setupBlock = { clearAppData(packageName) }, + ) { + pressHome() + startActivityAndWait() + } + + private fun clearAppData(packageName: String) { + val fileDescriptor = + InstrumentationRegistry.getInstrumentation() + .uiAutomation + .executeShellCommand("pm clear $packageName") + val fileInputStream = FileInputStream(fileDescriptor.fileDescriptor) + // Read the output to ensure the app data was cleared successfully + val result = fileInputStream.bufferedReader().use { it.readText().trim() } + fileDescriptor.close() + if (result != "Success") { + throw IllegalStateException("Unable to clear app data for $packageName - $result") + } + } + + private companion object { + const val PACKAGE_NAME = "com.google.firebase.testing.sessions" + } } diff --git a/firebase-sessions/firebase-sessions.gradle.kts b/firebase-sessions/firebase-sessions.gradle.kts index b136a281660..6c4b56a1c3b 100644 --- a/firebase-sessions/firebase-sessions.gradle.kts +++ b/firebase-sessions/firebase-sessions.gradle.kts @@ -21,6 +21,7 @@ plugins { id("firebase-vendor") id("kotlin-android") id("kotlin-kapt") + id("kotlinx-serialization") } firebaseLibrary { @@ -28,7 +29,11 @@ firebaseLibrary { testLab.enabled = true publishJavadoc = false - releaseNotes { enabled.set(false) } + + releaseNotes { + enabled = false + hasKTX = false + } } android { @@ -76,7 +81,8 @@ dependencies { implementation("com.google.android.datatransport:transport-api:3.2.0") implementation(libs.javax.inject) implementation(libs.androidx.annotation) - implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore) + implementation(libs.kotlinx.serialization.json) vendor(libs.dagger.dagger) { exclude(group = "javax.inject", module = "javax.inject") } diff --git a/firebase-sessions/gradle.properties b/firebase-sessions/gradle.properties index f2704e85093..b6163415afe 100644 --- a/firebase-sessions/gradle.properties +++ b/firebase-sessions/gradle.properties @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version=2.1.3 +version=2.2.0 latestReleasedVersion=2.1.2 diff --git a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt b/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt index 1cf67e0c5e1..33799a64d77 100644 --- a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt +++ b/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt @@ -20,12 +20,10 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.initialize import com.google.firebase.sessions.settings.SessionsSettings -import org.junit.After -import org.junit.Before +import org.junit.BeforeClass import org.junit.Test import org.junit.runner.RunWith @@ -36,23 +34,6 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class FirebaseSessionsTests { - @Before - fun setUp() { - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(APP_ID) - .setApiKey(API_KEY) - .setProjectId(PROJECT_ID) - .build() - ) - } - - @After - fun cleanUp() { - FirebaseApp.clearInstancesForTest() - } - @Test fun firebaseSessionsDoesInitialize() { assertThat(FirebaseSessions.instance).isNotNull() @@ -61,7 +42,6 @@ class FirebaseSessionsTests { @Test fun firebaseSessionsDependenciesDoInitialize() { assertThat(SessionFirelogPublisher.instance).isNotNull() - assertThat(SessionGenerator.instance).isNotNull() assertThat(SessionsSettings.instance).isNotNull() } @@ -69,5 +49,18 @@ class FirebaseSessionsTests { private const val APP_ID = "1:1:android:1a" private const val API_KEY = "API-KEY-API-KEY-API-KEY-API-KEY-API-KEY" private const val PROJECT_ID = "PROJECT-ID" + + @BeforeClass + @JvmStatic + fun setUp() { + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(APP_ID) + .setApiKey(API_KEY) + .setProjectId(PROJECT_ID) + .build(), + ) + } } } diff --git a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt b/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt deleted file mode 100644 index 49106b742af..00000000000 --- a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2024 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. - */ - -package com.google.firebase.sessions - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.ServiceTestRule -import com.google.common.truth.Truth.assertThat -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class SessionLifecycleServiceBinderTest { - @get:Rule val serviceRule = ServiceTestRule() - - @Test - fun bindSessionLifecycleService() { - val serviceConnection = - object : ServiceConnection { - var connected: Boolean = false - - override fun onServiceConnected(className: ComponentName?, serviceBinder: IBinder?) { - connected = true - } - - override fun onServiceDisconnected(className: ComponentName?) { - connected = false - } - } - - val sessionLifecycleServiceIntent = - Intent(ApplicationProvider.getApplicationContext(), SessionLifecycleService::class.java) - - serviceRule.bindService( - sessionLifecycleServiceIntent, - serviceConnection, - Context.BIND_IMPORTANT or Context.BIND_AUTO_CREATE, - ) - - assertThat(serviceConnection.connected).isTrue() - } -} diff --git a/firebase-sessions/src/main/AndroidManifest.xml b/firebase-sessions/src/main/AndroidManifest.xml index 181dcb1eee7..f0f9609f4e7 100644 --- a/firebase-sessions/src/main/AndroidManifest.xml +++ b/firebase-sessions/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + @@ -19,11 +18,8 @@ - + android:exported="false" + android:name="com.google.firebase.components.ComponentDiscoveryService"> diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt index 496cc70d36d..25656396fff 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt @@ -21,6 +21,7 @@ import com.google.android.datatransport.Encoding import com.google.android.datatransport.Event import com.google.android.datatransport.TransportFactory import com.google.firebase.inject.Provider +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import javax.inject.Inject import javax.inject.Singleton @@ -61,8 +62,6 @@ constructor(private val transportFactoryProvider: Provider) : } companion object { - private const val TAG = "EventGDTLogger" - private const val AQS_LOG_SOURCE = "FIREBASE_APPQUALITY_SESSION" } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt index 18b9961724b..59b6d9c201f 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt @@ -38,14 +38,14 @@ constructor( private val firebaseApp: FirebaseApp, private val settings: SessionsSettings, @Background backgroundDispatcher: CoroutineContext, - lifecycleServiceBinder: SessionLifecycleServiceBinder, + sessionsActivityLifecycleCallbacks: SessionsActivityLifecycleCallbacks, ) { init { Log.d(TAG, "Initializing Firebase Sessions SDK.") val appContext = firebaseApp.applicationContext.applicationContext if (appContext is Application) { - appContext.registerActivityLifecycleCallbacks(SessionsActivityLifecycleCallbacks) + appContext.registerActivityLifecycleCallbacks(sessionsActivityLifecycleCallbacks) CoroutineScope(backgroundDispatcher).launch { val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() @@ -56,16 +56,12 @@ constructor( if (!settings.sessionsEnabled) { Log.d(TAG, "Sessions SDK disabled. Not listening to lifecycle events.") } else { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher) - lifecycleClient.bindToService(lifecycleServiceBinder) - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - firebaseApp.addLifecycleEventListener { _, _ -> Log.w( TAG, "FirebaseApp instance deleted. Sessions library will stop collecting data.", ) - SessionsActivityLifecycleCallbacks.lifecycleClient = null + sessionsActivityLifecycleCallbacks.onAppDelete() } } } @@ -79,7 +75,7 @@ constructor( } companion object { - private const val TAG = "FirebaseSessions" + internal const val TAG = "FirebaseSessions" val instance: FirebaseSessions get() = Firebase.app[FirebaseSessions::class.java] diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt index 5680c9cc0ec..70f818c570f 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt @@ -18,37 +18,40 @@ package com.google.firebase.sessions import android.content.Context import android.util.Log +import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.MultiProcessDataStoreFactory +import androidx.datastore.core.Serializer import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.datastore.dataStoreFile import com.google.android.datatransport.TransportFactory import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background import com.google.firebase.annotations.concurrent.Blocking import com.google.firebase.inject.Provider import com.google.firebase.installations.FirebaseInstallationsApi -import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.settings.CrashlyticsSettingsFetcher import com.google.firebase.sessions.settings.LocalOverrideSettings import com.google.firebase.sessions.settings.RemoteSettings import com.google.firebase.sessions.settings.RemoteSettingsFetcher +import com.google.firebase.sessions.settings.SessionConfigs +import com.google.firebase.sessions.settings.SessionConfigsSerializer import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.settings.SettingsCache +import com.google.firebase.sessions.settings.SettingsCacheImpl import com.google.firebase.sessions.settings.SettingsProvider import dagger.Binds import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import java.io.File import javax.inject.Qualifier import javax.inject.Singleton import kotlin.coroutines.CoroutineContext - -@Qualifier internal annotation class SessionConfigsDataStore - -@Qualifier internal annotation class SessionDetailsDataStore +import kotlinx.coroutines.CoroutineScope @Qualifier internal annotation class LocalOverrideSettingsProvider @@ -64,10 +67,10 @@ import kotlin.coroutines.CoroutineContext internal interface FirebaseSessionsComponent { val firebaseSessions: FirebaseSessions - val sessionDatastore: SessionDatastore val sessionFirelogPublisher: SessionFirelogPublisher val sessionGenerator: SessionGenerator val sessionsSettings: SessionsSettings + val sharedSessionRepository: SharedSessionRepository @Component.Builder interface Builder { @@ -93,18 +96,10 @@ internal interface FirebaseSessionsComponent { interface MainModule { @Binds @Singleton fun eventGDTLoggerInterface(impl: EventGDTLogger): EventGDTLoggerInterface - @Binds @Singleton fun sessionDatastore(impl: SessionDatastoreImpl): SessionDatastore - @Binds @Singleton fun sessionFirelogPublisher(impl: SessionFirelogPublisherImpl): SessionFirelogPublisher - @Binds - @Singleton - fun sessionLifecycleServiceBinder( - impl: SessionLifecycleServiceBinderImpl - ): SessionLifecycleServiceBinder - @Binds @Singleton fun crashlyticsSettingsFetcher(impl: RemoteSettingsFetcher): CrashlyticsSettingsFetcher @@ -119,9 +114,15 @@ internal interface FirebaseSessionsComponent { @RemoteSettingsProvider fun remoteSettings(impl: RemoteSettings): SettingsProvider - companion object { - private const val TAG = "FirebaseSessions" + @Binds @Singleton fun settingsCache(impl: SettingsCacheImpl): SettingsCache + @Binds + @Singleton + fun sharedSessionRepository(impl: SharedSessionRepositoryImpl): SharedSessionRepository + + @Binds @Singleton fun processDataManager(impl: ProcessDataManagerImpl): ProcessDataManager + + companion object { @Provides @Singleton fun timeProvider(): TimeProvider = TimeProviderImpl @Provides @Singleton fun uuidGenerator(): UuidGenerator = UuidGeneratorImpl @@ -133,30 +134,68 @@ internal interface FirebaseSessionsComponent { @Provides @Singleton - @SessionConfigsDataStore - fun sessionConfigsDataStore(appContext: Context): DataStore = - PreferenceDataStoreFactory.create( + fun sessionConfigsDataStore( + appContext: Context, + @Blocking blockingDispatcher: CoroutineContext, + ): DataStore = + createDataStore( + serializer = SessionConfigsSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> - Log.w(TAG, "CorruptionException in settings DataStore in ${getProcessName()}.", ex) - emptyPreferences() - } - ) { - appContext.preferencesDataStoreFile(SessionDataStoreConfigs.SETTINGS_CONFIG_NAME) - } + Log.w(TAG, "CorruptionException in session configs DataStore", ex) + SessionConfigsSerializer.defaultValue + }, + scope = CoroutineScope(blockingDispatcher), + produceFile = { appContext.dataStoreFile("aqs/sessionConfigsDataStore.data") }, + ) @Provides @Singleton - @SessionDetailsDataStore - fun sessionDetailsDataStore(appContext: Context): DataStore = - PreferenceDataStoreFactory.create( + fun sessionDataStore( + appContext: Context, + @Blocking blockingDispatcher: CoroutineContext, + sessionDataSerializer: SessionDataSerializer, + ): DataStore = + createDataStore( + serializer = sessionDataSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> - Log.w(TAG, "CorruptionException in sessions DataStore in ${getProcessName()}.", ex) - emptyPreferences() - } - ) { - appContext.preferencesDataStoreFile(SessionDataStoreConfigs.SESSIONS_CONFIG_NAME) + Log.w(TAG, "CorruptionException in session data DataStore", ex) + sessionDataSerializer.defaultValue + }, + scope = CoroutineScope(blockingDispatcher), + produceFile = { appContext.dataStoreFile("aqs/sessionDataStore.data") }, + ) + + private fun createDataStore( + serializer: Serializer, + corruptionHandler: ReplaceFileCorruptionHandler, + migrations: List> = listOf(), + scope: CoroutineScope, + produceFile: () -> File, + ): DataStore = + if (loadDataStoreSharedCounter()) { + MultiProcessDataStoreFactory.create( + serializer, + corruptionHandler, + migrations, + scope, + produceFile, + ) + } else { + DataStoreFactory.create(serializer, corruptionHandler, migrations, scope, produceFile) + } + + /** This native library in unavailable in some conditions, for example, Robolectric tests */ + // TODO(mrober): Remove this when b/392626815 is resolved + private fun loadDataStoreSharedCounter(): Boolean = + try { + System.loadLibrary("datastore_shared_counter") + true + } catch (_: UnsatisfiedLinkError) { + false + } catch (_: SecurityException) { + false } } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt index 5cb8de7a182..3d66959bdcd 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt @@ -19,7 +19,7 @@ package com.google.firebase.sessions import android.content.Context import android.util.Log import androidx.annotation.Keep -import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.core.MultiProcessDataStoreFactory import com.google.android.datatransport.TransportFactory import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background @@ -31,6 +31,7 @@ import com.google.firebase.components.Qualified.qualified import com.google.firebase.components.Qualified.unqualified import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.platforminfo.LibraryVersionComponent +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import kotlinx.coroutines.CoroutineDispatcher /** @@ -71,7 +72,6 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { ) private companion object { - const val TAG = "FirebaseSessions" const val LIBRARY_NAME = "fire-sessions" val appContext = unqualified(Context::class.java) @@ -84,7 +84,7 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { init { try { - ::preferencesDataStore.javaClass + MultiProcessDataStoreFactory.javaClass } catch (ex: NoClassDefFoundError) { Log.w( TAG, diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt index 0df42fda953..69a7c4f4330 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt @@ -18,13 +18,12 @@ package com.google.firebase.sessions import android.util.Log import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import kotlinx.coroutines.tasks.await /** Provides the Firebase installation id and Firebase authentication token. */ internal class InstallationId private constructor(val fid: String, val authToken: String) { companion object { - private const val TAG = "InstallationId" - suspend fun create(firebaseInstallations: FirebaseInstallationsApi): InstallationId { // Fetch the auth token first, so the fid will be validated. val authToken: String = diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt new file mode 100644 index 00000000000..295b6550ed7 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import android.content.Context +import android.os.Process +import javax.inject.Inject +import javax.inject.Singleton + +/** Manage process data, used for detecting cold app starts. */ +internal interface ProcessDataManager { + /** This process's name. */ + val myProcessName: String + + /** This process's pid. */ + val myPid: Int + + /** An in-memory uuid to uniquely identify this instance of this process, not the uid. */ + val myUuid: String + + /** Checks if this is a cold app start, meaning all processes in the mapping table are stale. */ + fun isColdStart(processDataMap: Map): Boolean + + /** Checks if this process is stale. */ + fun isMyProcessStale(processDataMap: Map): Boolean + + /** Call to notify the process data manager that a session has been generated. */ + fun onSessionGenerated() + + /** Update the mapping of the current processes with data about this process. */ + fun updateProcessDataMap(processDataMap: Map?): Map + + /** Generate a new mapping of process data about this process only. */ + fun generateProcessDataMap(): Map = updateProcessDataMap(emptyMap()) +} + +@Singleton +internal class ProcessDataManagerImpl +@Inject +constructor(private val appContext: Context, uuidGenerator: UuidGenerator) : ProcessDataManager { + /** + * This process's name. + * + * This value is cached, so will not reflect changes to the process name during runtime. + */ + override val myProcessName: String by lazy { myProcessDetails.processName } + + override val myPid = Process.myPid() + + override val myUuid: String by lazy { uuidGenerator.next().toString() } + + private val myProcessDetails by lazy { ProcessDetailsProvider.getMyProcessDetails(appContext) } + + private var hasGeneratedSession: Boolean = false + + override fun isColdStart(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + // This process has been notified that a session was generated, so cannot be a cold start + return false + } + + // A cold start is when all app processes are stale + return getAppProcessDetails() + .mapNotNull { processDetails -> + processDataMap[processDetails.processName]?.let { processData -> + Pair(processDetails, processData) + } + } + .all { (processDetails, processData) -> isProcessStale(processDetails, processData) } + } + + override fun isMyProcessStale(processDataMap: Map): Boolean { + val myProcessData = processDataMap[myProcessName] ?: return true + return myProcessData.pid != myPid || myProcessData.uuid != myUuid + } + + override fun onSessionGenerated() { + hasGeneratedSession = true + } + + override fun updateProcessDataMap( + processDataMap: Map? + ): Map = + processDataMap + ?.toMutableMap() + ?.apply { this[myProcessName] = ProcessData(Process.myPid(), myUuid) } + ?.toMap() + ?: mapOf(myProcessName to ProcessData(Process.myPid(), myUuid)) + + /** Gets the current details for all of the app's running processes. */ + private fun getAppProcessDetails() = ProcessDetailsProvider.getAppProcessDetails(appContext) + + /** + * Returns true if the process is stale, meaning the persisted process data does not match the + * running process details. + * + * The [processDetails] is the running process details, and [processData] is the persisted data. + */ + private fun isProcessStale(processDetails: ProcessDetails, processData: ProcessData): Boolean = + if (myProcessName == processDetails.processName) { + // For this process, check pid and uuid + processDetails.pid != processData.pid || myUuid != processData.uuid + } else { + // For other processes, only check pid to avoid inter-process communication + // It is very unlikely for there to be a pid collision + processDetails.pid != processData.pid + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt index 65d1dfbbc60..39a3c03ed17 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt @@ -23,13 +23,9 @@ import android.os.Build import android.os.Process import com.google.android.gms.common.util.ProcessUtils -/** - * Provider of ProcessDetails. - * - * @hide - */ +/** Provide [ProcessDetails] for all app processes. */ internal object ProcessDetailsProvider { - /** Gets the details for all of this app's running processes. */ + /** Gets the details for all the app's running processes. */ fun getAppProcessDetails(context: Context): List { val appUid = context.applicationInfo.uid val defaultProcessName = context.applicationInfo.processName @@ -53,27 +49,19 @@ internal object ProcessDetailsProvider { } /** - * Gets this app's current process details. + * Gets this process's details. * - * If the current process details are not found for whatever reason, returns process details with - * just the current process name and pid set. + * If this process's full details are not found for whatever reason, returns process details with + * just the process name and pid set. */ - fun getCurrentProcessDetails(context: Context): ProcessDetails { + fun getMyProcessDetails(context: Context): ProcessDetails { val pid = Process.myPid() return getAppProcessDetails(context).find { it.pid == pid } - ?: buildProcessDetails(getProcessName(), pid) + ?: ProcessDetails(getProcessName(), pid, importance = 0, isDefaultProcess = false) } - /** Builds a ProcessDetails object. */ - private fun buildProcessDetails( - processName: String, - pid: Int = 0, - importance: Int = 0, - isDefaultProcess: Boolean = false - ) = ProcessDetails(processName, pid, importance, isDefaultProcess) - /** Gets the app's current process name. If it could not be found, returns an empty string. */ - internal fun getProcessName(): String { + private fun getProcessName(): String { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { return Process.myProcessName() } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt new file mode 100644 index 00000000000..8af2eee544d --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Session data to be persisted. */ +@Serializable +internal data class SessionData( + val sessionDetails: SessionDetails, + val backgroundTime: Time? = null, + val processDataMap: Map? = null, +) + +/** Data about a process, for persistence. */ +@Serializable internal data class ProcessData(val pid: Int, val uuid: String) + +/** DataStore json [Serializer] for [SessionData]. */ +@Singleton +internal class SessionDataSerializer +@Inject +constructor(private val sessionGenerator: SessionGenerator) : Serializer { + override val defaultValue: SessionData + get() = SessionData(sessionGenerator.generateNewSession(currentSession = null)) + + override suspend fun readFrom(input: InputStream): SessionData = + try { + Json.decodeFromString(input.readBytes().decodeToString()) + } catch (ex: Exception) { + throw CorruptionException("Cannot parse session data", ex) + } + + override suspend fun writeTo(t: SessionData, output: OutputStream) { + @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls + output.write(Json.encodeToString(SessionData.serializer(), t).encodeToByteArray()) + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt deleted file mode 100644 index 109e980e666..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.util.Base64 -import com.google.firebase.sessions.settings.SessionsSettings - -/** - * Util object for handling DataStore configs in multi-process apps safely. - * - * This can be removed when datastore-preferences:1.1.0 becomes stable. - */ -internal object SessionDataStoreConfigs { - /** Sanitized process name to use in config filenames. */ - private val PROCESS_NAME = - Base64.encodeToString( - ProcessDetailsProvider.getProcessName().encodeToByteArray(), - Base64.NO_WRAP or Base64.URL_SAFE, // URL safe is also filename safe. - ) - - /** Config name for [SessionDatastore] */ - val SESSIONS_CONFIG_NAME = "firebase_session_${PROCESS_NAME}_data" - - /** Config name for [SessionsSettings] */ - val SETTINGS_CONFIG_NAME = "firebase_session_${PROCESS_NAME}_settings" -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt deleted file mode 100644 index 2c4f243f942..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.util.Log -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey -import com.google.firebase.Firebase -import com.google.firebase.annotations.concurrent.Background -import com.google.firebase.app -import java.io.IOException -import java.util.concurrent.atomic.AtomicReference -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -/** Datastore for sessions information */ -internal data class FirebaseSessionsData(val sessionId: String?) - -/** Handles reading to and writing from the [DataStore]. */ -internal interface SessionDatastore { - /** Stores a new session ID value in the [DataStore] */ - fun updateSessionId(sessionId: String) - - /** - * Gets the currently stored session ID from the [DataStore]. This will be null if no session has - * been stored previously. - */ - fun getCurrentSessionId(): String? - - companion object { - val instance: SessionDatastore - get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionDatastore - } -} - -@Singleton -internal class SessionDatastoreImpl -@Inject -constructor( - @Background private val backgroundDispatcher: CoroutineContext, - @SessionDetailsDataStore private val dataStore: DataStore, -) : SessionDatastore { - - /** Most recent session from datastore is updated asynchronously whenever it changes */ - private val currentSessionFromDatastore = AtomicReference() - - private object FirebaseSessionDataKeys { - val SESSION_ID = stringPreferencesKey("session_id") - } - - private val firebaseSessionDataFlow: Flow = - dataStore.data - .catch { exception -> - Log.e(TAG, "Error reading stored session data.", exception) - emit(emptyPreferences()) - } - .map { preferences -> mapSessionsData(preferences) } - - init { - CoroutineScope(backgroundDispatcher).launch { - firebaseSessionDataFlow.collect { currentSessionFromDatastore.set(it) } - } - } - - override fun updateSessionId(sessionId: String) { - CoroutineScope(backgroundDispatcher).launch { - try { - dataStore.edit { preferences -> - preferences[FirebaseSessionDataKeys.SESSION_ID] = sessionId - } - } catch (e: IOException) { - Log.w(TAG, "Failed to update session Id: $e") - } - } - } - - override fun getCurrentSessionId() = currentSessionFromDatastore.get()?.sessionId - - private fun mapSessionsData(preferences: Preferences): FirebaseSessionsData = - FirebaseSessionsData(preferences[FirebaseSessionDataKeys.SESSION_ID]) - - private companion object { - private const val TAG = "FirebaseSessionsRepo" - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt new file mode 100644 index 00000000000..c5f71fb65e5 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import kotlinx.serialization.Serializable + +/** Details about the current session. */ +@Serializable +internal data class SessionDetails( + val sessionId: String, + val firstSessionId: String, + val sessionIndex: Int, + val sessionStartTimestampUs: Long, +) diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt index 25b3cbeb15d..864b393d64b 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt @@ -63,7 +63,6 @@ internal object SessionEvents { fun getApplicationInfo(firebaseApp: FirebaseApp): ApplicationInfo { val context = firebaseApp.applicationContext val packageName = context.packageName - @Suppress("DEPRECATION") // TODO(mrober): Use ApplicationInfoFlags when target sdk set to 33 val packageInfo = context.packageManager.getPackageInfo(packageName, 0) val buildVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -84,7 +83,7 @@ internal object SessionEvents { versionName = packageInfo.versionName ?: buildVersion, appBuildVersion = buildVersion, deviceManufacturer = Build.MANUFACTURER, - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext), + ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext), ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext), ), ) diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt index 6e4b6153f8d..21cb379cacb 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt @@ -22,6 +22,7 @@ import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background import com.google.firebase.app import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.settings.SessionsSettings import javax.inject.Inject @@ -34,7 +35,7 @@ import kotlinx.coroutines.launch internal fun interface SessionFirelogPublisher { /** Asynchronously logs the session represented by the given [SessionDetails] to Firelog. */ - fun logSession(sessionDetails: SessionDetails) + fun mayLogSession(sessionDetails: SessionDetails) companion object { val instance: SessionFirelogPublisher @@ -64,7 +65,7 @@ constructor( * This will pull all the necessary information about the device in order to create a full * [SessionEvent], and then upload that through the Firelog interface. */ - override fun logSession(sessionDetails: SessionDetails) { + override fun mayLogSession(sessionDetails: SessionDetails) { CoroutineScope(backgroundDispatcher).launch { if (shouldLogSession()) { val installationId = InstallationId.create(firebaseInstallations) @@ -94,13 +95,16 @@ constructor( /** Determines if the SDK should log a session to Firelog. */ private suspend fun shouldLogSession(): Boolean { - Log.d(TAG, "Data Collection is enabled for at least one Subscriber") - + val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() + if (subscribers.values.none { it.isDataCollectionEnabled }) { + Log.d(TAG, "Sessions SDK disabled through data collection. Events will not be sent.") + return false + } // This will cause remote settings to be fetched if the cache is expired. sessionSettings.updateSettings() if (!sessionSettings.sessionsEnabled) { - Log.d(TAG, "Sessions SDK disabled. Events will not be sent.") + Log.d(TAG, "Sessions SDK disabled through settings API. Events will not be sent.") return false } @@ -119,8 +123,6 @@ constructor( } internal companion object { - private const val TAG = "SessionFirelogPublisher" - private val randomValueForSampling: Double = Math.random() } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt index 4c4775e8b24..888b3d4729b 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt @@ -16,22 +16,9 @@ package com.google.firebase.sessions -import com.google.errorprone.annotations.CanIgnoreReturnValue -import com.google.firebase.Firebase -import com.google.firebase.app import javax.inject.Inject import javax.inject.Singleton -/** - * [SessionDetails] is a data class responsible for storing information about the current Session. - */ -internal data class SessionDetails( - val sessionId: String, - val firstSessionId: String, - val sessionIndex: Int, - val sessionStartTimestampUs: Long, -) - /** * The [SessionGenerator] is responsible for generating the Session ID, and keeping the * [SessionDetails] up to date with the latest values. @@ -40,35 +27,20 @@ internal data class SessionDetails( internal class SessionGenerator @Inject constructor(private val timeProvider: TimeProvider, private val uuidGenerator: UuidGenerator) { - private val firstSessionId = generateSessionId() - private var sessionIndex = -1 - - /** The current generated session, must not be accessed before calling [generateNewSession]. */ - lateinit var currentSession: SessionDetails - private set - - /** Returns if a session has been generated. */ - val hasGenerateSession: Boolean - get() = ::currentSession.isInitialized - - /** Generates a new session. The first session's sessionId will match firstSessionId. */ - @CanIgnoreReturnValue - fun generateNewSession(): SessionDetails { - sessionIndex++ - currentSession = - SessionDetails( - sessionId = if (sessionIndex == 0) firstSessionId else generateSessionId(), - firstSessionId, - sessionIndex, - sessionStartTimestampUs = timeProvider.currentTimeUs(), - ) - return currentSession + /** + * Generates a new session. + * + * If a current session is provided, will maintain the first session id and appropriate index. + */ + fun generateNewSession(currentSession: SessionDetails?): SessionDetails { + val newSessionId = generateSessionId() + return SessionDetails( + sessionId = newSessionId, + firstSessionId = currentSession?.firstSessionId ?: newSessionId, + sessionIndex = currentSession?.sessionIndex?.inc() ?: 0, + sessionStartTimestampUs = timeProvider.currentTime().us, + ) } private fun generateSessionId() = uuidGenerator.next().toString().replace("-", "").lowercase() - - internal companion object { - val instance: SessionGenerator - get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionGenerator - } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt deleted file mode 100644 index 900068af00d..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.os.Message -import android.os.Messenger -import android.os.RemoteException -import android.util.Log -import com.google.errorprone.annotations.CanIgnoreReturnValue -import com.google.firebase.sessions.api.FirebaseSessionsDependencies -import com.google.firebase.sessions.api.SessionSubscriber -import java.util.concurrent.LinkedBlockingDeque -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * Client for binding to the [SessionLifecycleService]. This client will receive updated sessions - * through a callback whenever a new session is generated by the service, or after the initial - * binding. - * - * Note: this client will be connected in every application process that uses Firebase, and is - * intended to maintain that connection for the lifetime of the process. - */ -internal class SessionLifecycleClient(private val backgroundDispatcher: CoroutineContext) { - - private var service: Messenger? = null - private var serviceBound: Boolean = false - private val queuedMessages = LinkedBlockingDeque(MAX_QUEUED_MESSAGES) - - /** - * The callback class that will be used to receive updated session events from the - * [SessionLifecycleService]. - */ - internal class ClientUpdateHandler(private val backgroundDispatcher: CoroutineContext) : - Handler(Looper.getMainLooper()) { - - override fun handleMessage(msg: Message) { - when (msg.what) { - SessionLifecycleService.SESSION_UPDATED -> - handleSessionUpdate( - msg.data?.getString(SessionLifecycleService.SESSION_UPDATE_EXTRA) ?: "" - ) - else -> { - Log.w(TAG, "Received unexpected event from the SessionLifecycleService: $msg") - super.handleMessage(msg) - } - } - } - - private fun handleSessionUpdate(sessionId: String) { - Log.d(TAG, "Session update received.") - - CoroutineScope(backgroundDispatcher).launch { - FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> - // Notify subscribers, regardless of sampling and data collection state. - subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) - Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId") - } - } - } - } - - /** The connection object to the [SessionLifecycleService]. */ - private val serviceConnection = - object : ServiceConnection { - override fun onServiceConnected(className: ComponentName?, serviceBinder: IBinder?) { - Log.d(TAG, "Connected to SessionLifecycleService. Queue size ${queuedMessages.size}") - service = Messenger(serviceBinder) - serviceBound = true - sendLifecycleEvents(drainQueue()) - } - - override fun onServiceDisconnected(className: ComponentName?) { - Log.d(TAG, "Disconnected from SessionLifecycleService") - service = null - serviceBound = false - } - } - - /** - * Binds to the [SessionLifecycleService] and passes a callback [Messenger] that will be used to - * relay session updates to this client. - */ - fun bindToService(sessionLifecycleServiceBinder: SessionLifecycleServiceBinder) { - sessionLifecycleServiceBinder.bindToService( - Messenger(ClientUpdateHandler(backgroundDispatcher)), - serviceConnection, - ) - } - - /** - * Should be called when any activity in this application process goes to the foreground. This - * will relay the event to the [SessionLifecycleService] where it can make the determination of - * whether or not this foregrounding event should result in a new session being generated. - */ - fun foregrounded() { - sendLifecycleEvent(SessionLifecycleService.FOREGROUNDED) - } - - /** - * Should be called when any activity in this application process goes from the foreground to the - * background. This will relay the event to the [SessionLifecycleService] where it will be used to - * determine when a new session should be generated. - */ - fun backgrounded() { - sendLifecycleEvent(SessionLifecycleService.BACKGROUNDED) - } - - /** - * Sends a message to the [SessionLifecycleService] with the given event code. This will - * potentially also send any messages that have been queued up but not successfully delivered to - * this service since the previous send. - */ - private fun sendLifecycleEvent(messageCode: Int) { - val allMessages = drainQueue() - allMessages.add(Message.obtain(null, messageCode, 0, 0)) - sendLifecycleEvents(allMessages) - } - - /** - * Sends lifecycle events to the [SessionLifecycleService]. This will only send the latest - * FOREGROUND and BACKGROUND events to the service that are included in the given list. Running - * through the full backlog of messages is not useful since the service only cares about the - * current state and transitions from background -> foreground. - * - * Does not send events unless data collection is enabled for at least one subscriber. - */ - @CanIgnoreReturnValue - private fun sendLifecycleEvents(messages: List) = - CoroutineScope(backgroundDispatcher).launch { - val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() - if (subscribers.isEmpty()) { - Log.d( - TAG, - "Sessions SDK did not have any dependent SDKs register as dependencies. Events will not be sent.", - ) - } else if (subscribers.values.none { it.isDataCollectionEnabled }) { - Log.d(TAG, "Data Collection is disabled for all subscribers. Skipping this Event") - } else { - mutableListOf( - getLatestByCode(messages, SessionLifecycleService.BACKGROUNDED), - getLatestByCode(messages, SessionLifecycleService.FOREGROUNDED), - ) - .filterNotNull() - .sortedBy { it.getWhen() } - .forEach { sendMessageToServer(it) } - } - } - - /** Sends the given [Message] to the [SessionLifecycleService]. */ - private fun sendMessageToServer(msg: Message) { - if (service != null) { - try { - Log.d(TAG, "Sending lifecycle ${msg.what} to service") - service?.send(msg) - } catch (e: RemoteException) { - Log.w(TAG, "Unable to deliver message: ${msg.what}", e) - queueMessage(msg) - } - } else { - queueMessage(msg) - } - } - - /** - * Queues the given [Message] up for delivery to the [SessionLifecycleService] once the connection - * is established. - */ - private fun queueMessage(msg: Message) { - if (queuedMessages.offer(msg)) { - Log.d(TAG, "Queued message ${msg.what}. Queue size ${queuedMessages.size}") - } else { - Log.d(TAG, "Failed to enqueue message ${msg.what}. Dropping.") - } - } - - /** Drains the queue of messages into a new list in a thread-safe manner. */ - private fun drainQueue(): MutableList { - val messages = mutableListOf() - queuedMessages.drainTo(messages) - return messages - } - - /** Gets the message in the given list with the given code that has the latest timestamp. */ - private fun getLatestByCode(messages: List, msgCode: Int): Message? = - messages.filter { it.what == msgCode }.maxByOrNull { it.getWhen() } - - companion object { - const val TAG = "SessionLifecycleClient" - - /** - * The maximum number of messages that we should queue up for delivery to the - * [SessionLifecycleService] in the event that we have lost the connection. - */ - private const val MAX_QUEUED_MESSAGES = 20 - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt deleted file mode 100644 index 85930dc5455..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.app.Service -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.DeadObjectException -import android.os.Handler -import android.os.HandlerThread -import android.os.IBinder -import android.os.Looper -import android.os.Message -import android.os.Messenger -import android.util.Log -import com.google.firebase.sessions.settings.SessionsSettings - -/** - * Service for monitoring application lifecycle events and determining when/if a new session should - * be generated. When this happens, the service will broadcast the updated session id to all - * connected clients. - */ -internal class SessionLifecycleService : Service() { - - /** The thread that will be used to process all lifecycle messages from connected clients. */ - internal val handlerThread: HandlerThread = HandlerThread("FirebaseSessions_HandlerThread") - - /** The handler that will process all lifecycle messages from connected clients . */ - private var messageHandler: MessageHandler? = null - - /** The single messenger that will be sent to all connected clients of this service . */ - private var messenger: Messenger? = null - - /** - * Handler of incoming activity lifecycle events being received from [SessionLifecycleClient]s. - * All incoming communication from connected clients comes through this class and will be used to - * determine when new sessions should be created. - */ - internal class MessageHandler(looper: Looper) : Handler(looper) { - - /** - * Flag indicating whether or not the app has ever come into the foreground during the lifetime - * of the service. If it has not, we can infer that the first foreground event is a cold-start - * - * Note: this is made volatile because we attempt to send the current session ID to newly bound - * clients, and this binding happens - */ - private var hasForegrounded: Boolean = false - - /** - * The timestamp of the last activity lifecycle message we've received from a client. Used to - * determine when the app has been idle for long enough to require a new session. - */ - private var lastMsgTimeMs: Long = 0 - - /** Queue of connected clients. */ - private val boundClients = ArrayList() - - override fun handleMessage(msg: Message) { - if (lastMsgTimeMs > msg.getWhen()) { - Log.d(TAG, "Ignoring old message from ${msg.getWhen()} which is older than $lastMsgTimeMs.") - return - } - when (msg.what) { - FOREGROUNDED -> handleForegrounding(msg) - BACKGROUNDED -> handleBackgrounding(msg) - CLIENT_BOUND -> handleClientBound(msg) - else -> { - Log.w(TAG, "Received unexpected event from the SessionLifecycleClient: $msg") - super.handleMessage(msg) - } - } - } - - /** - * Handles a foregrounding event by any activity owned by the application as specified by the - * given [Message]. This will determine if the foregrounding should result in the creation of a - * new session. - */ - private fun handleForegrounding(msg: Message) { - Log.d(TAG, "Activity foregrounding at ${msg.getWhen()}.") - if (!hasForegrounded) { - Log.d(TAG, "Cold start detected.") - hasForegrounded = true - newSession() - } else if (isSessionRestart(msg.getWhen())) { - Log.d(TAG, "Session too long in background. Creating new session.") - newSession() - } - lastMsgTimeMs = msg.getWhen() - } - - /** - * Handles a backgrounding event by any activity owned by the application as specified by the - * given [Message]. This will keep track of the backgrounding and be used to determine if future - * foregrounding events should result in the creation of a new session. - */ - private fun handleBackgrounding(msg: Message) { - Log.d(TAG, "Activity backgrounding at ${msg.getWhen()}") - lastMsgTimeMs = msg.getWhen() - } - - /** - * Handles a newly bound client to this service by adding it to the list of callback clients and - * attempting to send it the latest session id immediately. - */ - private fun handleClientBound(msg: Message) { - boundClients.add(msg.replyTo) - maybeSendSessionToClient(msg.replyTo) - Log.d(TAG, "Client ${msg.replyTo} bound at ${msg.getWhen()}. Clients: ${boundClients.size}") - } - - /** Generates a new session id and sends it everywhere it's needed */ - private fun newSession() { - try { - SessionGenerator.instance.generateNewSession() - Log.d(TAG, "Generated new session.") - broadcastSession() - SessionDatastore.instance.updateSessionId( - SessionGenerator.instance.currentSession.sessionId - ) - } catch (ex: IllegalStateException) { - Log.w(TAG, "Failed to generate new session.", ex) - } - } - - /** - * Broadcasts the current session to by uploading to Firelog and all sending a message to all - * connected clients. - */ - private fun broadcastSession() { - Log.d(TAG, "Broadcasting new session") - SessionFirelogPublisher.instance.logSession(SessionGenerator.instance.currentSession) - // Create a defensive copy because DeadObjectExceptions on send will modify boundClients - val clientsToSend = ArrayList(boundClients) - clientsToSend.forEach { maybeSendSessionToClient(it) } - } - - private fun maybeSendSessionToClient(client: Messenger) { - try { - if (hasForegrounded) { - sendSessionToClient(client, SessionGenerator.instance.currentSession.sessionId) - } else { - // Send the value from the datastore before the first foregrounding it exists - val storedSession = SessionDatastore.instance.getCurrentSessionId() - Log.d(TAG, "App has not yet foregrounded. Using previously stored session.") - storedSession?.let { sendSessionToClient(client, it) } - } - } catch (ex: IllegalStateException) { - Log.w(TAG, "Failed to send session to client.", ex) - } - } - - /** Sends the current session id to the client connected through the given [Messenger]. */ - private fun sendSessionToClient(client: Messenger, sessionId: String) { - try { - val msgData = Bundle().also { it.putString(SESSION_UPDATE_EXTRA, sessionId) } - client.send(Message.obtain(null, SESSION_UPDATED, 0, 0).also { it.data = msgData }) - } catch (e: DeadObjectException) { - Log.d(TAG, "Removing dead client from list: $client") - boundClients.remove(client) - } catch (e: Exception) { - Log.w(TAG, "Unable to push new session to $client.", e) - } - } - - /** - * Determines if the foregrounding that occurred at the given time should trigger a new session - * because the app has been idle for too long. - */ - private fun isSessionRestart(foregroundTimeMs: Long) = - (foregroundTimeMs - lastMsgTimeMs) > - SessionsSettings.instance.sessionRestartTimeout.inWholeMilliseconds - } - - override fun onCreate() { - super.onCreate() - handlerThread.start() - messageHandler = MessageHandler(handlerThread.looper) - messenger = Messenger(messageHandler) - Log.d(TAG, "Service created on process ${android.os.Process.myPid()}") - } - - /** Called when a new [SessionLifecycleClient] binds to this service. */ - override fun onBind(intent: Intent?): IBinder? = - if (intent == null) { - Log.d(TAG, "Service bound with null intent. Ignoring.") - null - } else { - Log.d(TAG, "Service bound to new client on process ${intent.action}") - val callbackMessenger = getClientCallback(intent) - if (callbackMessenger != null) { - val clientBoundMsg = Message.obtain(null, CLIENT_BOUND, 0, 0) - clientBoundMsg.replyTo = callbackMessenger - messageHandler?.sendMessage(clientBoundMsg) - } - messenger?.binder - } - - override fun onDestroy() { - super.onDestroy() - handlerThread.quit() - } - - /** - * Extracts the callback [Messenger] from the given [Intent] which will be used to push session - * updates back to the [SessionLifecycleClient] that created this [Intent]. - */ - private fun getClientCallback(intent: Intent): Messenger? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(CLIENT_CALLBACK_MESSENGER, Messenger::class.java) - } else { - @Suppress("DEPRECATION") intent.getParcelableExtra(CLIENT_CALLBACK_MESSENGER) - } - - internal companion object { - const val TAG = "SessionLifecycleService" - - /** - * Key for the [Messenger] callback extra included in the [Intent] used by the - * [SessionLifecycleClient] to bind to this service. - */ - const val CLIENT_CALLBACK_MESSENGER = "ClientCallbackMessenger" - - /** - * Key for the extra String included in the [SESSION_UPDATED] message, sent to all connected - * clients, containing an updated session id. - */ - const val SESSION_UPDATE_EXTRA = "SessionUpdateExtra" - - /** [Message] code indicating that an application activity has gone to the foreground */ - const val FOREGROUNDED = 1 - /** [Message] code indicating that an application activity has gone to the background */ - const val BACKGROUNDED = 2 - /** - * [Message] code indicating that a new session has been started, and containing the new session - * id in the [SESSION_UPDATE_EXTRA] extra field. - */ - const val SESSION_UPDATED = 3 - - /** - * [Message] code indicating that a new client has been bound to the service. The - * [Message.replyTo] field will contain the new client callback interface. - */ - private const val CLIENT_BOUND = 4 - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt deleted file mode 100644 index 094a76ee51c..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Messenger -import android.util.Log -import javax.inject.Inject -import javax.inject.Singleton - -/** Interface for binding with the [SessionLifecycleService]. */ -internal fun interface SessionLifecycleServiceBinder { - /** - * Binds the given client callback [Messenger] to the [SessionLifecycleService]. The given - * callback will be used to relay session updates to this client. - */ - fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) -} - -@Singleton -internal class SessionLifecycleServiceBinderImpl -@Inject -constructor(private val appContext: Context) : SessionLifecycleServiceBinder { - - override fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) { - Intent(appContext, SessionLifecycleService::class.java).also { intent -> - Log.d(TAG, "Binding service to application.") - // This is necessary for the onBind() to be called by each process - intent.action = android.os.Process.myPid().toString() - intent.putExtra(SessionLifecycleService.CLIENT_CALLBACK_MESSENGER, callback) - intent.setPackage(appContext.packageName) - - val isServiceBound = - try { - appContext.bindService( - intent, - serviceConnection, - Context.BIND_IMPORTANT or Context.BIND_AUTO_CREATE, - ) - } catch (ex: SecurityException) { - Log.w(TAG, "Failed to bind session lifecycle service to application.", ex) - false - } - if (!isServiceBound) { - unbindServiceSafely(appContext, serviceConnection) - Log.i(TAG, "Session lifecycle service binding failed.") - } - } - } - - private fun unbindServiceSafely(appContext: Context, serviceConnection: ServiceConnection) = - try { - appContext.unbindService(serviceConnection) - } catch (ex: IllegalArgumentException) { - Log.w(TAG, "Session lifecycle service binding failed.", ex) - } - - private companion object { - const val TAG = "LifecycleServiceBinder" - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt index b72c1da5cf3..0cdfbd1a498 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt @@ -19,33 +19,34 @@ package com.google.firebase.sessions import android.app.Activity import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle -import androidx.annotation.VisibleForTesting +import javax.inject.Inject +import javax.inject.Singleton /** - * Lifecycle callbacks that will inform the [SessionLifecycleClient] whenever an [Activity] in this + * Lifecycle callbacks that will inform the [SharedSessionRepository] whenever an [Activity] in this * application process goes foreground or background. */ -internal object SessionsActivityLifecycleCallbacks : ActivityLifecycleCallbacks { - @VisibleForTesting internal var hasPendingForeground: Boolean = false - - var lifecycleClient: SessionLifecycleClient? = null - /** Sets the client and calls [SessionLifecycleClient.foregrounded] for pending foreground. */ - set(lifecycleClient) { - field = lifecycleClient - lifecycleClient?.let { - if (hasPendingForeground) { - hasPendingForeground = false - it.foregrounded() - } - } - } +@Singleton +internal class SessionsActivityLifecycleCallbacks +@Inject +constructor(private val sharedSessionRepository: SharedSessionRepository) : + ActivityLifecycleCallbacks { + private var enabled = true + + fun onAppDelete() { + enabled = false + } override fun onActivityResumed(activity: Activity) { - lifecycleClient?.foregrounded() ?: run { hasPendingForeground = true } + if (enabled) { + sharedSessionRepository.appForeground() + } } override fun onActivityPaused(activity: Activity) { - lifecycleClient?.backgrounded() + if (enabled) { + sharedSessionRepository.appBackground() + } } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt new file mode 100644 index 00000000000..2c33c173d95 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import android.util.Log +import androidx.datastore.core.DataStore +import com.google.firebase.Firebase +import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.app +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG +import com.google.firebase.sessions.api.FirebaseSessionsDependencies +import com.google.firebase.sessions.api.SessionSubscriber +import com.google.firebase.sessions.settings.SessionsSettings +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch + +/** Repository to persist session data to be shared between all app processes. */ +internal interface SharedSessionRepository { + val isInForeground: Boolean + + fun appBackground() + + fun appForeground() + + companion object { + val instance: SharedSessionRepository + get() = Firebase.app[FirebaseSessionsComponent::class.java].sharedSessionRepository + } +} + +@Singleton +internal class SharedSessionRepositoryImpl +@Inject +constructor( + private val sessionsSettings: SessionsSettings, + private val sessionGenerator: SessionGenerator, + private val sessionFirelogPublisher: SessionFirelogPublisher, + private val timeProvider: TimeProvider, + private val sessionDataStore: DataStore, + private val processDataManager: ProcessDataManager, + @Background private val backgroundDispatcher: CoroutineContext, +) : SharedSessionRepository { + /** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */ + internal lateinit var localSessionData: SessionData + + override var isInForeground = false + private set + + /** + * Either notify the subscribers with general multi-process supported session or fallback local + * session + */ + internal enum class NotificationType { + GENERAL, + FALLBACK, + } + + internal var previousNotificationType: NotificationType = NotificationType.GENERAL + private var previousSessionId: String = "" + + init { + CoroutineScope(backgroundDispatcher).launch { + sessionDataStore.data + .catch { + val newSession = + SessionData( + sessionDetails = sessionGenerator.generateNewSession(null), + backgroundTime = null, + ) + Log.d( + TAG, + "Init session datastore failed with exception message: ${it.message}. Emit fallback session ${newSession.sessionDetails.sessionId}", + ) + emit(newSession) + } + .collect { sessionData -> + localSessionData = sessionData + val sessionId = sessionData.sessionDetails.sessionId + notifySubscribers(sessionId, NotificationType.GENERAL) + } + } + } + + override fun appBackground() { + isInForeground = false + if (!::localSessionData.isInitialized) { + Log.d(TAG, "App backgrounded, but local SessionData not initialized") + return + } + val sessionData = localSessionData + Log.d(TAG, "App backgrounded on ${processDataManager.myProcessName} - $sessionData") + + CoroutineScope(backgroundDispatcher).launch { + try { + sessionDataStore.updateData { + sessionData.copy(backgroundTime = timeProvider.currentTime()) + } + } catch (ex: Exception) { + Log.d(TAG, "App backgrounded, failed to update data. Message: ${ex.message}") + localSessionData = localSessionData.copy(backgroundTime = timeProvider.currentTime()) + } + } + } + + override fun appForeground() { + isInForeground = true + if (!::localSessionData.isInitialized) { + Log.d(TAG, "App foregrounded, but local SessionData not initialized") + return + } + val sessionData = localSessionData + Log.d(TAG, "App foregrounded on ${processDataManager.myProcessName} - $sessionData") + + // Check if maybe the session data needs to be updated + if (isSessionExpired(sessionData) || isMyProcessStale(sessionData)) { + CoroutineScope(backgroundDispatcher).launch { + try { + sessionDataStore.updateData { currentSessionData -> + // Check again using the current session data on disk + val isSessionExpired = isSessionExpired(currentSessionData) + val isColdStart = isColdStart(currentSessionData) + val isMyProcessStale = isMyProcessStale(currentSessionData) + + val newProcessDataMap = + if (isColdStart) { + // Generate a new process data map for cold app start + processDataManager.generateProcessDataMap() + } else if (isMyProcessStale) { + // Update the data map with this process if stale + processDataManager.updateProcessDataMap(currentSessionData.processDataMap) + } else { + // No change + currentSessionData.processDataMap + } + + val currentSession = + if (isColdStart) { + // For a cold start, do not keep the current session + null + } else { + currentSessionData.sessionDetails + } + + // This is an expression, and returns the updated session data + if (isSessionExpired || isColdStart) { + val newSessionDetails = sessionGenerator.generateNewSession(currentSession) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + processDataManager.onSessionGenerated() + currentSessionData.copy( + sessionDetails = newSessionDetails, + backgroundTime = null, + processDataMap = newProcessDataMap, + ) + } else if (isMyProcessStale) { + currentSessionData.copy( + processDataMap = processDataManager.updateProcessDataMap(newProcessDataMap) + ) + } else { + currentSessionData + } + } + } catch (ex: Exception) { + Log.d(TAG, "App foregrounded, failed to update data. Message: ${ex.message}") + if (isSessionExpired(sessionData)) { + val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) + localSessionData = + sessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + notifySubscribers(newSessionDetails.sessionId, NotificationType.FALLBACK) + } + } + } + } + } + + private suspend fun notifySubscribers(sessionId: String, type: NotificationType) { + previousNotificationType = type + if (previousSessionId == sessionId) { + return + } + previousSessionId = sessionId + FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> + // Notify subscribers, regardless of sampling and data collection state + subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) + Log.d( + TAG, + when (type) { + NotificationType.GENERAL -> + "Notified ${subscriber.sessionSubscriberName} of new session $sessionId" + NotificationType.FALLBACK -> + "Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId" + }, + ) + } + } + + /** Checks if the session has expired. If no background time, consider it not expired. */ + private fun isSessionExpired(sessionData: SessionData): Boolean { + sessionData.backgroundTime?.let { backgroundTime -> + val interval = timeProvider.currentTime() - backgroundTime + val sessionExpired = (interval > sessionsSettings.sessionRestartTimeout) + if (sessionExpired) { + Log.d(TAG, "Session ${sessionData.sessionDetails.sessionId} is expired") + } + return sessionExpired + } + + Log.d(TAG, "Session ${sessionData.sessionDetails.sessionId} has not backgrounded yet") + return false + } + + /** Checks for cold app start. If no process data map, consider it a cold start. */ + private fun isColdStart(sessionData: SessionData): Boolean { + sessionData.processDataMap?.let { processDataMap -> + val coldStart = processDataManager.isColdStart(processDataMap) + if (coldStart) { + Log.d(TAG, "Cold app start detected") + } + return coldStart + } + + Log.d(TAG, "No process data map") + return true + } + + /** Checks if this process is stale. If no process data map, consider the process stale. */ + private fun isMyProcessStale(sessionData: SessionData): Boolean { + sessionData.processDataMap?.let { processDataMap -> + val myProcessStale = processDataManager.isMyProcessStale(processDataMap) + if (myProcessStale) { + Log.d(TAG, "Process ${processDataManager.myProcessName} is stale") + } + return myProcessStale + } + + Log.d(TAG, "No process data for ${processDataManager.myProcessName}") + return true + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt index b66b09af19f..933decdd3e1 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt @@ -19,12 +19,22 @@ package com.google.firebase.sessions import android.os.SystemClock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlinx.serialization.Serializable + +/** Time with accessors for microseconds, milliseconds, and seconds. */ +@Serializable +internal data class Time(val ms: Long) { + val us = ms * 1_000 + val seconds = ms / 1_000 + + operator fun minus(time: Time): Duration = (ms - time.ms).milliseconds +} /** Time provider interface, for testing purposes. */ internal interface TimeProvider { fun elapsedRealtime(): Duration - fun currentTimeUs(): Long + fun currentTime(): Time } /** "Wall clock" time provider implementation. */ @@ -38,14 +48,11 @@ internal object TimeProviderImpl : TimeProvider { override fun elapsedRealtime(): Duration = SystemClock.elapsedRealtime().milliseconds /** - * Gets the current "wall clock" time in microseconds. + * Gets the current "wall clock" time. * * This clock can be set by the user or the phone network, so the time may jump backwards or * forwards unpredictably. This clock should only be used when correspondence with real-world * dates and times is important, such as in a calendar or alarm clock application. */ - override fun currentTimeUs(): Long = System.currentTimeMillis() * US_PER_MILLIS - - /** Microseconds per millisecond. */ - private const val US_PER_MILLIS = 1000L + override fun currentTime(): Time = Time(ms = System.currentTimeMillis()) } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt new file mode 100644 index 00000000000..9318ed944e0 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions.api + +import androidx.annotation.VisibleForTesting +import com.google.firebase.sessions.SharedSessionRepository + +/** + * Internal API used by Firebase Crashlytics to notify the Firebase Sessions SDK of fatal crashes. + * + * This object provides a static-like entry point that Crashlytics calls to inform Sessions a fatal + * crash has occurred. + */ +object CrashEventReceiver { + @VisibleForTesting internal lateinit var sharedSessionRepository: SharedSessionRepository + + /** + * Notifies the Firebase Sessions SDK that a fatal crash has occurred. + * + * This method should be called by Firebase Crashlytics as soon as it detects a fatal crash. It + * safely processes the crash notification, treating the crash as a background event, to ensure + * that the session state is updated correctly. + * + * @see SharedSessionRepository.appBackground + */ + @JvmStatic + fun notifyCrashOccurred() { + try { + if (!::sharedSessionRepository.isInitialized) { + sharedSessionRepository = SharedSessionRepository.instance + } + // Treat a foreground crash as if the app went to the background, and update session state. + if (sharedSessionRepository.isInForeground) { + sharedSessionRepository.appBackground() + } + } catch (_: Exception) { + // Catch and suppress any exception to avoid crashing during crash handling. + // This can occur if Firebase or the SDK are in an unexpected state (e.g. FirebaseApp deleted) + // No action needed, avoid interfering with the crash reporting process. + } + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt index 8d3548c8f4b..adf3ce8c950 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt @@ -18,6 +18,7 @@ package com.google.firebase.sessions.api import android.util.Log import androidx.annotation.VisibleForTesting +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import java.util.Collections.synchronizedMap import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -30,8 +31,6 @@ import kotlinx.coroutines.sync.withLock * This is important because the Sessions SDK starts up before dependent SDKs. */ object FirebaseSessionsDependencies { - private const val TAG = "SessionsDependencies" - private val dependencies = synchronizedMap(mutableMapOf()) /** @@ -40,19 +39,6 @@ object FirebaseSessionsDependencies { */ @JvmStatic fun addDependency(subscriberName: SessionSubscriber.Name) { - if (subscriberName == SessionSubscriber.Name.PERFORMANCE) { - throw IllegalArgumentException( - """ - Incompatible versions of Firebase Perf and Firebase Sessions. - A safe combination would be: - firebase-sessions:1.1.0 - firebase-crashlytics:18.5.0 - firebase-perf:20.5.0 - For more information contact Firebase Support. - """ - .trimIndent() - ) - } if (dependencies.containsKey(subscriberName)) { Log.d(TAG, "Dependency $subscriberName already added.") return diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt index 67a48bc7924..1b25202b4f9 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt @@ -19,18 +19,16 @@ package com.google.firebase.sessions.settings import android.os.Build import android.util.Log import androidx.annotation.VisibleForTesting -import com.google.firebase.annotations.concurrent.Background import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.sessions.ApplicationInfo +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.InstallationId -import dagger.Lazy +import com.google.firebase.sessions.TimeProvider import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.CoroutineContext import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONException @@ -40,15 +38,12 @@ import org.json.JSONObject internal class RemoteSettings @Inject constructor( - @Background private val backgroundDispatcher: CoroutineContext, + private val timeProvider: TimeProvider, private val firebaseInstallationsApi: FirebaseInstallationsApi, private val appInfo: ApplicationInfo, private val configsFetcher: CrashlyticsSettingsFetcher, - private val lazySettingsCache: Lazy, + private val settingsCache: SettingsCache, ) : SettingsProvider { - private val settingsCache: SettingsCache - get() = lazySettingsCache.get() - private val fetchInProgress = Mutex() override val sessionEnabled: Boolean? @@ -90,10 +85,9 @@ constructor( val options = mapOf( "X-Crashlytics-Installation-ID" to installationId, - "X-Crashlytics-Device-Model" to - removeForwardSlashesIn(String.format("%s/%s", Build.MANUFACTURER, Build.MODEL)), - "X-Crashlytics-OS-Build-Version" to removeForwardSlashesIn(Build.VERSION.INCREMENTAL), - "X-Crashlytics-OS-Display-Version" to removeForwardSlashesIn(Build.VERSION.RELEASE), + "X-Crashlytics-Device-Model" to sanitize("${Build.MANUFACTURER}${Build.MODEL}"), + "X-Crashlytics-OS-Build-Version" to sanitize(Build.VERSION.INCREMENTAL), + "X-Crashlytics-OS-Display-Version" to sanitize(Build.VERSION.RELEASE), "X-Crashlytics-API-Client-Version" to appInfo.sessionSdkVersion, ) @@ -129,22 +123,19 @@ constructor( } } - sessionsEnabled?.let { settingsCache.updateSettingsEnabled(sessionsEnabled) } - - sessionTimeoutSeconds?.let { - settingsCache.updateSessionRestartTimeout(sessionTimeoutSeconds) - } - - sessionSamplingRate?.let { settingsCache.updateSamplingRate(sessionSamplingRate) } - - cacheDuration?.let { settingsCache.updateSessionCacheDuration(cacheDuration) } - ?: let { settingsCache.updateSessionCacheDuration(86400) } - - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = sessionsEnabled, + sessionTimeoutSeconds = sessionTimeoutSeconds, + sessionSamplingRate = sessionSamplingRate, + cacheDurationSeconds = cacheDuration ?: defaultCacheDuration, + cacheUpdatedTimeSeconds = timeProvider.currentTime().seconds, + ) + ) }, onFailure = { msg -> // Network request failed here. - Log.e(TAG, "Error failing to fetch the remote configs: $msg") + Log.e(TAG, "Error failed to fetch the remote configs: $msg") }, ) } @@ -153,18 +144,15 @@ constructor( override fun isSettingsStale(): Boolean = settingsCache.hasCacheExpired() @VisibleForTesting - internal fun clearCachedSettings() { - val scope = CoroutineScope(backgroundDispatcher) - scope.launch { settingsCache.removeConfigs() } + internal suspend fun clearCachedSettings() { + settingsCache.updateConfigs(SessionConfigsSerializer.defaultValue) } - private fun removeForwardSlashesIn(s: String): String { - return s.replace(FORWARD_SLASH_STRING.toRegex(), "") - } + private fun sanitize(s: String) = s.replace(sanitizeRegex, "") private companion object { - const val TAG = "SessionConfigFetcher" + val defaultCacheDuration = 24.hours.inWholeSeconds.toInt() - const val FORWARD_SLASH_STRING: String = "/" + val sanitizeRegex = "/".toRegex() } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt index 92d530f2fa1..bd45ec8fb24 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt @@ -17,7 +17,7 @@ package com.google.firebase.sessions.settings import android.net.Uri -import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.annotations.concurrent.Blocking import com.google.firebase.sessions.ApplicationInfo import java.io.BufferedReader import java.io.InputStreamReader @@ -42,7 +42,7 @@ internal class RemoteSettingsFetcher @Inject constructor( private val appInfo: ApplicationInfo, - @Background private val blockingDispatcher: CoroutineContext, + @Blocking private val blockingDispatcher: CoroutineContext, ) : CrashlyticsSettingsFetcher { @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls. override suspend fun doConfigFetch( diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt new file mode 100644 index 00000000000..ab310ebed8a --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions.settings + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Session configs data for caching. */ +@Serializable +internal data class SessionConfigs( + val sessionsEnabled: Boolean?, + val sessionSamplingRate: Double?, + val sessionTimeoutSeconds: Int?, + val cacheDurationSeconds: Int?, + val cacheUpdatedTimeSeconds: Long?, +) + +/** DataStore json [Serializer] for [SessionConfigs]. */ +internal object SessionConfigsSerializer : Serializer { + override val defaultValue = + SessionConfigs( + sessionsEnabled = null, + sessionSamplingRate = null, + sessionTimeoutSeconds = null, + cacheDurationSeconds = null, + cacheUpdatedTimeSeconds = null, + ) + + override suspend fun readFrom(input: InputStream): SessionConfigs = + try { + Json.decodeFromString(input.readBytes().decodeToString()) + } catch (ex: Exception) { + throw CorruptionException("Cannot parse session configs", ex) + } + + override suspend fun writeTo(t: SessionConfigs, output: OutputStream) { + @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls + output.write(Json.encodeToString(SessionConfigs.serializer(), t).encodeToByteArray()) + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt index 2e60e51650a..6b5cc96a138 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt @@ -19,126 +19,93 @@ package com.google.firebase.sessions.settings import android.util.Log import androidx.annotation.VisibleForTesting import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.doublePreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import com.google.firebase.sessions.SessionConfigsDataStore +import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG +import com.google.firebase.sessions.TimeProvider import java.io.IOException +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -internal data class SessionConfigs( - val sessionEnabled: Boolean?, - val sessionSamplingRate: Double?, - val sessionRestartTimeout: Int?, - val cacheDuration: Int?, - val cacheUpdatedTime: Long?, -) +internal interface SettingsCache { + fun hasCacheExpired(): Boolean + + fun sessionsEnabled(): Boolean? + + fun sessionSamplingRate(): Double? + + fun sessionRestartTimeout(): Int? + + suspend fun updateConfigs(sessionConfigs: SessionConfigs) +} @Singleton -internal class SettingsCache +internal class SettingsCacheImpl @Inject -constructor(@SessionConfigsDataStore private val dataStore: DataStore) { - private lateinit var sessionConfigs: SessionConfigs +constructor( + @Background private val backgroundDispatcher: CoroutineContext, + private val timeProvider: TimeProvider, + private val sessionConfigsDataStore: DataStore, +) : SettingsCache { + private val sessionConfigsAtomicReference = AtomicReference() + + private val sessionConfigs: SessionConfigs + get() { + // Ensure configs are loaded from disk before the first access + if (sessionConfigsAtomicReference.get() == null) { + // Double check to avoid the `runBlocking` unless necessary + sessionConfigsAtomicReference.compareAndSet( + null, + runBlocking { sessionConfigsDataStore.data.first() }, + ) + } - init { - // Block until the cache is loaded from disk to ensure cache - // values are valid and readable from the main thread on init. - runBlocking { updateSessionConfigs(dataStore.data.first().toPreferences()) } - } + return sessionConfigsAtomicReference.get() + } - /** Update session configs from the given [preferences]. */ - private fun updateSessionConfigs(preferences: Preferences) { - sessionConfigs = - SessionConfigs( - sessionEnabled = preferences[SESSIONS_ENABLED], - sessionSamplingRate = preferences[SAMPLING_RATE], - sessionRestartTimeout = preferences[RESTART_TIMEOUT_SECONDS], - cacheDuration = preferences[CACHE_DURATION_SECONDS], - cacheUpdatedTime = preferences[CACHE_UPDATED_TIME], - ) + init { + CoroutineScope(backgroundDispatcher).launch { + sessionConfigsDataStore.data.collect(sessionConfigsAtomicReference::set) + } } - internal fun hasCacheExpired(): Boolean { - val cacheUpdatedTime = sessionConfigs.cacheUpdatedTime - val cacheDuration = sessionConfigs.cacheDuration + override fun hasCacheExpired(): Boolean { + val cacheUpdatedTimeSeconds = sessionConfigs.cacheUpdatedTimeSeconds + val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds - if (cacheUpdatedTime != null && cacheDuration != null) { - val timeDifferenceSeconds = (System.currentTimeMillis() - cacheUpdatedTime) / 1000 - if (timeDifferenceSeconds < cacheDuration) { + if (cacheUpdatedTimeSeconds != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = timeProvider.currentTime().seconds - cacheUpdatedTimeSeconds + if (timeDifferenceSeconds < cacheDurationSeconds) { return false } } return true } - fun sessionsEnabled(): Boolean? = sessionConfigs.sessionEnabled - - fun sessionSamplingRate(): Double? = sessionConfigs.sessionSamplingRate - - fun sessionRestartTimeout(): Int? = sessionConfigs.sessionRestartTimeout - - suspend fun updateSettingsEnabled(enabled: Boolean?) { - updateConfigValue(SESSIONS_ENABLED, enabled) - } - - suspend fun updateSamplingRate(rate: Double?) { - updateConfigValue(SAMPLING_RATE, rate) - } - - suspend fun updateSessionRestartTimeout(timeoutInSeconds: Int?) { - updateConfigValue(RESTART_TIMEOUT_SECONDS, timeoutInSeconds) - } + override fun sessionsEnabled(): Boolean? = sessionConfigs.sessionsEnabled - suspend fun updateSessionCacheDuration(cacheDurationInSeconds: Int?) { - updateConfigValue(CACHE_DURATION_SECONDS, cacheDurationInSeconds) - } + override fun sessionSamplingRate(): Double? = sessionConfigs.sessionSamplingRate - suspend fun updateSessionCacheUpdatedTime(cacheUpdatedTime: Long?) { - updateConfigValue(CACHE_UPDATED_TIME, cacheUpdatedTime) - } + override fun sessionRestartTimeout(): Int? = sessionConfigs.sessionTimeoutSeconds - @VisibleForTesting - internal suspend fun removeConfigs() { + override suspend fun updateConfigs(sessionConfigs: SessionConfigs) { try { - dataStore.edit { preferences -> - preferences.clear() - updateSessionConfigs(preferences) - } - } catch (e: IOException) { - Log.w(TAG, "Failed to remove config values: $e") + sessionConfigsDataStore.updateData { sessionConfigs } + } catch (ex: IOException) { + Log.w(TAG, "Failed to update config values: $ex") } } - /** Updated the config value, or remove the key if the value is null. */ - private suspend fun updateConfigValue(key: Preferences.Key, value: T?) { - // TODO(mrober): Refactor these to update all the values in one transaction. + @VisibleForTesting + internal suspend fun removeConfigs() = try { - dataStore.edit { preferences -> - if (value != null) { - preferences[key] = value - } else { - preferences.remove(key) - } - updateSessionConfigs(preferences) - } + sessionConfigsDataStore.updateData { SessionConfigsSerializer.defaultValue } } catch (ex: IOException) { - Log.w(TAG, "Failed to update cache config value: $ex") + Log.w(TAG, "Failed to remove config values: $ex") } - } - - private companion object { - const val TAG = "SettingsCache" - - val SESSIONS_ENABLED = booleanPreferencesKey("firebase_sessions_enabled") - val SAMPLING_RATE = doublePreferencesKey("firebase_sessions_sampling_rate") - val RESTART_TIMEOUT_SECONDS = intPreferencesKey("firebase_sessions_restart_timeout") - val CACHE_DURATION_SECONDS = intPreferencesKey("firebase_sessions_cache_duration") - val CACHE_UPDATED_TIME = longPreferencesKey("firebase_sessions_cache_updated_time") - } } diff --git a/firebase-sessions/src/test/AndroidManifest.xml b/firebase-sessions/src/test/AndroidManifest.xml deleted file mode 100644 index 4eccb7649da..00000000000 --- a/firebase-sessions/src/test/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt index 3c6ccf8644d..b026b7f33bc 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt @@ -36,9 +36,9 @@ class ApplicationInfoTest { @Test fun applicationInfo_populatesInfoCorrectly() { val firebaseApp = FakeFirebaseApp().firebaseApp - val actualCurrentProcessDetails = - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext) - val actualAppProcessDetails = + val myProcessDetails = + ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext) + val appProcessDetails = ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext) val applicationInfo = SessionEvents.getApplicationInfo(firebaseApp) assertThat(applicationInfo) @@ -54,15 +54,15 @@ class ApplicationInfoTest { versionName = FakeFirebaseApp.MOCK_APP_VERSION, appBuildVersion = FakeFirebaseApp.MOCK_APP_BUILD_VERSION, deviceManufacturer = Build.MANUFACTURER, - actualCurrentProcessDetails, - actualAppProcessDetails, - ) + myProcessDetails, + appProcessDetails, + ), ) ) } @Test - fun applicationInfo_missiongVersionCode_populatesInfoCorrectly() { + fun applicationInfo_missingVersionCode_populatesInfoCorrectly() { // Initialize Firebase with no version code set. val firebaseApp = Firebase.initialize( @@ -71,12 +71,12 @@ class ApplicationInfoTest { .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) .setApiKey(FakeFirebaseApp.MOCK_API_KEY) .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build() + .build(), ) - val actualCurrentProcessDetails = - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext) - val actualAppProcessDetails = + val myProcessDetails = + ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext) + val appProcessDetails = ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext) val applicationInfo = SessionEvents.getApplicationInfo(firebaseApp) @@ -94,9 +94,9 @@ class ApplicationInfoTest { versionName = "0", appBuildVersion = "0", deviceManufacturer = Build.MANUFACTURER, - actualCurrentProcessDetails, - actualAppProcessDetails, - ) + myProcessDetails, + appProcessDetails, + ), ) ) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt index b636d53e3dc..fb3e58f44d6 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt @@ -27,14 +27,12 @@ import com.google.firebase.sessions.testing.FakeProvider import com.google.firebase.sessions.testing.FakeSettingsProvider import com.google.firebase.sessions.testing.FakeTransportFactory import com.google.firebase.sessions.testing.TestSessionEventData -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class EventGDTLoggerTest { diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt new file mode 100644 index 00000000000..12a5e8f128e --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.sessions.testing.FakeDataStore +import java.io.IOException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests for the [FakeDataStore] implementation. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +internal class FakeDataStoreTest { + @Test + fun emitsProvidedValues() = runTest { + val fakeDataStore = FakeDataStore(23) + + val result = mutableListOf() + + // Collect data into result list + backgroundScope.launch { fakeDataStore.data.collect { result.add(it) } } + + fakeDataStore.updateData { 1 } + fakeDataStore.updateData { 2 } + fakeDataStore.updateData { 3 } + fakeDataStore.updateData { 4 } + + runCurrent() + + assertThat(result).containsExactly(23, 1, 2, 3, 4) + } + + @Test + fun throwsProvidedExceptionOnEmit() = runTest { + val fakeDataStore = FakeDataStore(23) + + val result = mutableListOf() + backgroundScope.launch { + fakeDataStore.data + .catch { ex -> result.add(ex.message!!) } + .collect { result.add(it.toString()) } + } + + fakeDataStore.updateData { 1 } + fakeDataStore.throwOnNextEmit(IOException("oops")) + + runCurrent() + + assertThat(result).containsExactly("23", "1", "oops") + } + + @Test(expected = IndexOutOfBoundsException::class) + fun throwsProvidedExceptionOnUpdateData() = runTest { + val fakeDataStore = FakeDataStore(23) + + fakeDataStore.updateData { 1 } + fakeDataStore.throwOnNextUpdateData(IndexOutOfBoundsException("oops")) + + // Expected to throw + fakeDataStore.updateData { 2 } + } + + @Test(expected = IllegalArgumentException::class) + fun throwsFirstProvidedExceptionOnCollect() = runTest { + val fakeDataStore = FakeDataStore(23, IllegalArgumentException("oops")) + + // Expected to throw + fakeDataStore.data.collect {} + } + + @Test(expected = IllegalStateException::class) + fun throwsFirstProvidedExceptionOnFirst() = runTest { + val fakeDataStore = FakeDataStore(23, IllegalStateException("oops")) + + // Expected to throw + fakeDataStore.data.first() + } + + @Test + fun consistentAfterManyUpdates() = runTest { + val fakeDataStore = FakeDataStore(0) + + var collectResult = 0 + backgroundScope.launch { fakeDataStore.data.collect { collectResult = it } } + + var updateResult = 0 + // 100 is bigger than the channel buffer size so this will cause suspending + repeat(100) { updateResult = fakeDataStore.updateData { it.inc() } } + + runCurrent() + + assertThat(collectResult).isEqualTo(100) + assertThat(updateResult).isEqualTo(100) + + fakeDataStore.close() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt new file mode 100644 index 00000000000..3eddd371a0f --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeRunningAppProcessInfo +import com.google.firebase.sessions.testing.FakeUuidGenerator +import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_1 as MY_UUID +import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2 as OTHER_UUID +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ProcessDataManagerTest { + @Test + fun isColdStart_myProcess() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID))) + + assertThat(coldStart).isFalse() + } + + @Test + fun isColdStart_emptyProcessDataMap() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = processDataManager.isColdStart(processDataMap = emptyMap()) + + assertThat(coldStart).isTrue() + } + + fun isColdStart_myProcessCurrent_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart( + mapOf( + MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), + ) + ) + + assertThat(coldStart).isFalse() + } + + @Test + fun isColdStart_staleProcessPid() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID))) + + assertThat(coldStart).isTrue() + } + + @Test + fun isColdStart_staleProcessUuid() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, OTHER_UUID))) + + assertThat(coldStart).isTrue() + } + + @Test + fun isColdStart_myProcessStale_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart( + mapOf( + MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), + ) + ) + + assertThat(coldStart).isFalse() + } + + @Test + fun isMyProcessStale() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo)).firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val myProcessStale = + processDataManager.isMyProcessStale(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID))) + + assertThat(myProcessStale).isFalse() + } + + @Test + fun isMyProcessStale_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val myProcessStale = + processDataManager.isMyProcessStale( + mapOf( + MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), + ) + ) + + assertThat(myProcessStale).isTrue() + } + + @Test + fun isMyProcessStale_missingMyProcessData() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val myProcessStale = + processDataManager.isMyProcessStale( + mapOf(OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID)) + ) + + assertThat(myProcessStale).isTrue() + } + + @After + fun cleanUp() { + FirebaseApp.clearInstancesForTest() + } + + private companion object { + const val MY_PROCESS_NAME = "com.google.firebase.sessions.test" + const val OTHER_PROCESS_NAME = "not.my.process" + + const val MY_PID = 0 + const val OTHER_PID = 4 + + val myProcessInfo = FakeRunningAppProcessInfo(pid = MY_PID, processName = MY_PROCESS_NAME) + + val otherProcessInfo = + FakeRunningAppProcessInfo(pid = OTHER_PID, processName = OTHER_PROCESS_NAME) + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt index b41b33e3361..2517157c7e2 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt @@ -40,9 +40,8 @@ class ProcessDetailsProviderTest { } @Test - fun getCurrentProcessDetails() { - val processDetails = - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext) + fun getMyProcessDetails() { + val processDetails = ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext) assertThat(processDetails) .isEqualTo(ProcessDetails("com.google.firebase.sessions.test", 0, 100, false)) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt index 6f919cf946b..70772e733cb 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt @@ -27,13 +27,11 @@ import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeSessionSubscriber import com.google.firebase.sessions.testing.FakeSettingsProvider import com.google.firebase.sessions.testing.TestSessionEventData -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class SessionEventEncoderTest { diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt index 682371491df..16fe6547ca6 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt @@ -27,14 +27,12 @@ import com.google.firebase.sessions.testing.TestSessionEventData.TEST_DATA_COLLE import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_DATA import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_DETAILS import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_EVENT -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SessionEventTest { @Test diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt index 01d6bba540b..76c6864f491 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt @@ -75,7 +75,7 @@ class SessionFirelogPublisherTest { ) // Construct an event with no fid set. - publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) + publisher.mayLogSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() @@ -105,7 +105,7 @@ class SessionFirelogPublisherTest { ) // Construct an event with no fid set. - publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) + publisher.mayLogSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() @@ -134,7 +134,7 @@ class SessionFirelogPublisherTest { ) // Construct an event with no fid set. - publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) + publisher.mayLogSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt index 7126bae4dbf..9aab92b766a 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt @@ -16,12 +16,15 @@ package com.google.firebase.sessions +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.sessions.testing.FakeTimeProvider import com.google.firebase.sessions.testing.FakeUuidGenerator -import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP_US +import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class SessionGeneratorTest { private fun isValidSessionId(sessionId: String): Boolean { if (sessionId.length != 32) { @@ -36,47 +39,21 @@ class SessionGeneratorTest { return true } - // This test case isn't important behavior. Nothing should access - // currentSession before generateNewSession has been called. - @Test(expected = UninitializedPropertyAccessException::class) - fun currentSession_beforeGenerate_throwsUninitialized() { - val sessionGenerator = - SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - - sessionGenerator.currentSession - } - - @Test - fun hasGenerateSession_beforeGenerate_returnsFalse() { - val sessionGenerator = - SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - - assertThat(sessionGenerator.hasGenerateSession).isFalse() - } - - @Test - fun hasGenerateSession_afterGenerate_returnsTrue() { - val sessionGenerator = - SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - - sessionGenerator.generateNewSession() - - assertThat(sessionGenerator.hasGenerateSession).isTrue() - } - @Test fun generateNewSession_generatesValidSessionIds() { val sessionGenerator = SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - sessionGenerator.generateNewSession() + val sessionDetails = sessionGenerator.generateNewSession(currentSession = null) - assertThat(isValidSessionId(sessionGenerator.currentSession.sessionId)).isTrue() - assertThat(isValidSessionId(sessionGenerator.currentSession.firstSessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.sessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.firstSessionId)).isTrue() // Validate several random session ids. + var currentSession = sessionDetails repeat(16) { - assertThat(isValidSessionId(sessionGenerator.generateNewSession().sessionId)).isTrue() + currentSession = sessionGenerator.generateNewSession(currentSession) + assertThat(isValidSessionId(currentSession.sessionId)).isTrue() } } @@ -85,18 +62,18 @@ class SessionGeneratorTest { val sessionGenerator = SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = FakeUuidGenerator()) - sessionGenerator.generateNewSession() + val sessionDetails = sessionGenerator.generateNewSession(currentSession = null) - assertThat(isValidSessionId(sessionGenerator.currentSession.sessionId)).isTrue() - assertThat(isValidSessionId(sessionGenerator.currentSession.firstSessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.sessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.firstSessionId)).isTrue() - assertThat(sessionGenerator.currentSession) + assertThat(sessionDetails) .isEqualTo( SessionDetails( sessionId = SESSION_ID_1, firstSessionId = SESSION_ID_1, sessionIndex = 0, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) } @@ -108,7 +85,7 @@ class SessionGeneratorTest { val sessionGenerator = SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = FakeUuidGenerator()) - val firstSessionDetails = sessionGenerator.generateNewSession() + val firstSessionDetails = sessionGenerator.generateNewSession(currentSession = null) assertThat(isValidSessionId(firstSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(firstSessionDetails.firstSessionId)).isTrue() @@ -119,11 +96,12 @@ class SessionGeneratorTest { sessionId = SESSION_ID_1, firstSessionId = SESSION_ID_1, sessionIndex = 0, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) - val secondSessionDetails = sessionGenerator.generateNewSession() + val secondSessionDetails = + sessionGenerator.generateNewSession(currentSession = firstSessionDetails) assertThat(isValidSessionId(secondSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(secondSessionDetails.firstSessionId)).isTrue() @@ -135,12 +113,13 @@ class SessionGeneratorTest { sessionId = SESSION_ID_2, firstSessionId = SESSION_ID_1, sessionIndex = 1, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) // Do a third round just in case - val thirdSessionDetails = sessionGenerator.generateNewSession() + val thirdSessionDetails = + sessionGenerator.generateNewSession(currentSession = secondSessionDetails) assertThat(isValidSessionId(thirdSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(thirdSessionDetails.firstSessionId)).isTrue() @@ -151,7 +130,7 @@ class SessionGeneratorTest { sessionId = SESSION_ID_3, firstSessionId = SESSION_ID_1, sessionIndex = 2, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt deleted file mode 100644 index 12a017a7462..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.os.Looper -import androidx.test.core.app.ApplicationProvider -import androidx.test.filters.MediumTest -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.initialize -import com.google.firebase.sessions.api.FirebaseSessionsDependencies -import com.google.firebase.sessions.api.SessionSubscriber -import com.google.firebase.sessions.api.SessionSubscriber.SessionDetails -import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FakeSessionLifecycleServiceBinder -import com.google.firebase.sessions.testing.FakeSessionSubscriber -import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf - -@OptIn(ExperimentalCoroutinesApi::class) -@MediumTest -@RunWith(RobolectricTestRunner::class) -internal class SessionLifecycleClientTest { - private lateinit var fakeService: FakeSessionLifecycleServiceBinder - private lateinit var lifecycleServiceBinder: SessionLifecycleServiceBinder - - @Before - fun setUp() { - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - - fakeService = FirebaseSessionsFakeComponent.instance.fakeSessionLifecycleServiceBinder - lifecycleServiceBinder = FirebaseSessionsFakeComponent.instance.sessionLifecycleServiceBinder - } - - @After - fun cleanUp() { - fakeService.serviceDisconnected() - FirebaseApp.clearInstancesForTest() - fakeService.clearForTest() - FirebaseSessionsDependencies.reset() - } - - @Test - fun bindToService_registersCallbacks() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - waitForMessages() - assertThat(fakeService.clientCallbacks).hasSize(1) - assertThat(fakeService.connectionCallbacks).hasSize(1) - } - - @Test - fun onServiceConnected_sendsQueuedMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - client.foregrounded() - client.backgrounded() - - fakeService.serviceConnected() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun onServiceConnected_sendsOnlyLatestMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - client.foregrounded() - client.backgrounded() - client.foregrounded() - client.backgrounded() - client.foregrounded() - client.backgrounded() - - fakeService.serviceConnected() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun onServiceDisconnected_noMoreEventsSent() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.serviceDisconnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).isEmpty() - } - - @Test - fun serviceReconnection_handlesNewMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.serviceDisconnected() - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun serviceReconnection_queuesOldMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.serviceDisconnected() - client.foregrounded() - client.backgrounded() - fakeService.serviceConnected() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun doesNotSendLifecycleEventsWithoutSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).isEmpty() - } - - @Test - fun doesNotSendLifecycleEventsWithoutEnabledSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(collectionEnabled = false, SessionSubscriber.Name.CRASHLYTICS) - addSubscriber(collectionEnabled = false, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).isEmpty() - } - - @Test - fun sendsLifecycleEventsWhenAtLeastOneEnabledSubscriber() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(collectionEnabled = true, SessionSubscriber.Name.CRASHLYTICS) - addSubscriber(collectionEnabled = false, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(2) - } - - @Test - fun handleSessionUpdate_noSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.broadcastSession("123") - - waitForMessages() - } - - @Test - fun handleSessionUpdate_sendsToSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - val crashlyticsSubscriber = addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - val mattSaysHiSubscriber = addSubscriber(true, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.broadcastSession("123") - - waitForMessages() - assertThat(crashlyticsSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - assertThat(mattSaysHiSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - } - - @Test - fun handleSessionUpdate_sendsToAllSubscribersAsLongAsOneIsEnabled() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - val crashlyticsSubscriber = addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - val mattSaysHiSubscriber = addSubscriber(false, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.broadcastSession("123") - - waitForMessages() - assertThat(crashlyticsSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - assertThat(mattSaysHiSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - } - - private fun addSubscriber( - collectionEnabled: Boolean, - name: SessionSubscriber.Name, - ): FakeSessionSubscriber { - val fakeSubscriber = FakeSessionSubscriber(collectionEnabled, sessionSubscriberName = name) - FirebaseSessionsDependencies.addDependency(name) - FirebaseSessionsDependencies.register(fakeSubscriber) - return fakeSubscriber - } - - private fun waitForMessages() { - shadowOf(Looper.getMainLooper()).idle() - } - - private fun backgroundDispatcher() = TestOnlyExecutors.background().asCoroutineDispatcher() -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt deleted file mode 100644 index ccd933f1213..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.content.Intent -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.os.Messenger -import androidx.test.core.app.ApplicationProvider -import androidx.test.filters.MediumTest -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.initialize -import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent -import java.time.Duration -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.android.controller.ServiceController -import org.robolectric.annotation.LooperMode -import org.robolectric.annotation.LooperMode.Mode.PAUSED -import org.robolectric.shadows.ShadowSystemClock - -@MediumTest -@LooperMode(PAUSED) -@RunWith(RobolectricTestRunner::class) -internal class SessionLifecycleServiceTest { - private lateinit var service: ServiceController - - data class CallbackMessage(val code: Int, val sessionId: String?) - - internal inner class TestCallbackHandler(looper: Looper = Looper.getMainLooper()) : - Handler(looper) { - val callbackMessages = ArrayList() - - override fun handleMessage(msg: Message) { - callbackMessages.add(CallbackMessage(msg.what, getSessionId(msg))) - } - } - - @Before - fun setUp() { - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - service = createService() - } - - @After - fun cleanUp() { - FirebaseApp.clearInstancesForTest() - } - - @Test - fun binding_noCallbackOnInitialBindingWhenNoneStored() { - val client = TestCallbackHandler() - - bindToService(client) - - waitForAllMessages() - assertThat(client.callbackMessages).isEmpty() - } - - @Test - fun binding_callbackOnInitialBindWhenSessionIdSet() { - val client = TestCallbackHandler() - FirebaseSessionsFakeComponent.instance.fakeSessionDatastore.updateSessionId("123") - - bindToService(client) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(1) - val msg = client.callbackMessages.first() - assertThat(msg.code).isEqualTo(SessionLifecycleService.SESSION_UPDATED) - assertThat(msg.sessionId).isNotEmpty() - // We should not send stored session IDs to firelog - assertThat(getUploadedSessions()).isEmpty() - } - - @Test - fun foregrounding_startsSessionOnFirstForegrounding() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(1) - assertThat(getUploadedSessions()).hasSize(1) - assertThat(client.callbackMessages.first().code) - .isEqualTo(SessionLifecycleService.SESSION_UPDATED) - assertThat(client.callbackMessages.first().sessionId).isNotEmpty() - assertThat(getUploadedSessions().first().sessionId) - .isEqualTo(client.callbackMessages.first().sessionId) - } - - @Test - fun foregrounding_onlyOneSessionOnMultipleForegroundings() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(1) - assertThat(getUploadedSessions()).hasSize(1) - } - - @Test - fun foregrounding_newSessionAfterLongDelay() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - ShadowSystemClock.advanceBy(Duration.ofMinutes(31)) - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(2) - assertThat(getUploadedSessions()).hasSize(2) - assertThat(client.callbackMessages.first().sessionId) - .isNotEqualTo(client.callbackMessages.last().sessionId) - assertThat(getUploadedSessions().first().sessionId) - .isEqualTo(client.callbackMessages.first().sessionId) - assertThat(getUploadedSessions().last().sessionId) - .isEqualTo(client.callbackMessages.last().sessionId) - } - - @Test - fun sendsSessionsToMultipleClients() { - val client1 = TestCallbackHandler() - val client2 = TestCallbackHandler() - val client3 = TestCallbackHandler() - bindToService(client1) - val messenger = bindToService(client2) - bindToService(client3) - waitForAllMessages() - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client1.callbackMessages).hasSize(1) - assertThat(client1.callbackMessages).isEqualTo(client2.callbackMessages) - assertThat(client1.callbackMessages).isEqualTo(client3.callbackMessages) - assertThat(getUploadedSessions()).hasSize(1) - } - - @Test - fun onlyOneSessionForMultipleClientsForegrounding() { - val client1 = TestCallbackHandler() - val client2 = TestCallbackHandler() - val client3 = TestCallbackHandler() - val messenger1 = bindToService(client1) - val messenger2 = bindToService(client2) - val messenger3 = bindToService(client3) - waitForAllMessages() - - messenger1.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger1.send(Message.obtain(null, SessionLifecycleService.BACKGROUNDED, 0, 0)) - messenger2.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger2.send(Message.obtain(null, SessionLifecycleService.BACKGROUNDED, 0, 0)) - messenger3.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client1.callbackMessages).hasSize(1) - assertThat(client1.callbackMessages).isEqualTo(client2.callbackMessages) - assertThat(client1.callbackMessages).isEqualTo(client3.callbackMessages) - assertThat(getUploadedSessions()).hasSize(1) - } - - @Test - fun backgrounding_doesNotStartSession() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.BACKGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).isEmpty() - assertThat(getUploadedSessions()).isEmpty() - } - - private fun bindToService(client: TestCallbackHandler): Messenger { - return Messenger(service.get()?.onBind(createServiceLaunchIntent(client))) - } - - private fun createServiceLaunchIntent(client: TestCallbackHandler) = - Intent(ApplicationProvider.getApplicationContext(), SessionLifecycleService::class.java).apply { - putExtra(SessionLifecycleService.CLIENT_CALLBACK_MESSENGER, Messenger(client)) - } - - private fun createService() = - Robolectric.buildService(SessionLifecycleService::class.java).create() - - private fun waitForAllMessages() { - shadowOf(service.get()?.handlerThread?.getLooper()).idle() - shadowOf(Looper.getMainLooper()).idle() - } - - private fun getUploadedSessions() = - FirebaseSessionsFakeComponent.instance.fakeFirelogPublisher.loggedSessions - - private fun getSessionId(msg: Message) = - msg.data?.getString(SessionLifecycleService.SESSION_UPDATE_EXTRA) -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt deleted file mode 100644 index 62e650d90c8..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions - -import android.app.Activity -import android.os.Looper -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.initialize -import com.google.firebase.sessions.api.FirebaseSessionsDependencies -import com.google.firebase.sessions.api.SessionSubscriber -import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FakeSessionLifecycleServiceBinder -import com.google.firebase.sessions.testing.FakeSessionSubscriber -import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Shadows - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -internal class SessionsActivityLifecycleCallbacksTest { - private lateinit var fakeService: FakeSessionLifecycleServiceBinder - private lateinit var lifecycleServiceBinder: SessionLifecycleServiceBinder - private val fakeActivity = Activity() - - @Before - fun setUp() { - // Reset the state of the SessionsActivityLifecycleCallbacks object. - SessionsActivityLifecycleCallbacks.hasPendingForeground = false - SessionsActivityLifecycleCallbacks.lifecycleClient = null - - FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.MATT_SAYS_HI) - FirebaseSessionsDependencies.register( - FakeSessionSubscriber( - isDataCollectionEnabled = true, - sessionSubscriberName = SessionSubscriber.Name.MATT_SAYS_HI, - ) - ) - - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - - fakeService = FirebaseSessionsFakeComponent.instance.fakeSessionLifecycleServiceBinder - lifecycleServiceBinder = FirebaseSessionsFakeComponent.instance.sessionLifecycleServiceBinder - } - - @After - fun cleanUp() { - fakeService.serviceDisconnected() - FirebaseApp.clearInstancesForTest() - fakeService.clearForTest() - FirebaseSessionsDependencies.reset() - } - - @Test - fun hasPendingForeground_thenSetLifecycleClient_callsBackgrounded() = - runTest(UnconfinedTestDispatcher()) { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher(coroutineContext)) - - // Activity comes to foreground before the lifecycle client was set due to no settings. - SessionsActivityLifecycleCallbacks.onActivityResumed(fakeActivity) - - // Settings fetched and set the lifecycle client. - lifecycleClient.bindToService(lifecycleServiceBinder) - fakeService.serviceConnected() - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - - // Assert lifecycleClient.foregrounded got called. - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(1) - } - - @Test - fun noPendingForeground_thenSetLifecycleClient_doesNotCallBackgrounded() = - runTest(UnconfinedTestDispatcher()) { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher(coroutineContext)) - - // Set lifecycle client before any foreground happened. - lifecycleClient.bindToService(lifecycleServiceBinder) - fakeService.serviceConnected() - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - - // Assert lifecycleClient.foregrounded did not get called. - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(0) - - // Activity comes to foreground. - SessionsActivityLifecycleCallbacks.onActivityResumed(fakeActivity) - - // Assert lifecycleClient.foregrounded did get called. - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(1) - } - - private fun waitForMessages() = Shadows.shadowOf(Looper.getMainLooper()).idle() - - private fun backgroundDispatcher(coroutineContext: CoroutineContext) = - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt new file mode 100644 index 00000000000..5adc708e36b --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.concurrent.TestOnlyExecutors +import com.google.firebase.sessions.SessionGeneratorTest.Companion.SESSION_ID_1 +import com.google.firebase.sessions.SessionGeneratorTest.Companion.SESSION_ID_2 +import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.testing.FakeDataStore +import com.google.firebase.sessions.testing.FakeEventGDTLogger +import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeFirebaseInstallations +import com.google.firebase.sessions.testing.FakeProcessDataManager +import com.google.firebase.sessions.testing.FakeSettingsProvider +import com.google.firebase.sessions.testing.FakeTimeProvider +import com.google.firebase.sessions.testing.FakeUuidGenerator +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SharedSessionRepositoryTest { + private val fakeFirebaseApp = FakeFirebaseApp() + private val fakeEventGDTLogger = FakeEventGDTLogger() + private val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD", "FakeAuthToken") + private var fakeTimeProvider = FakeTimeProvider() + private val sessionGenerator = SessionGenerator(fakeTimeProvider, FakeUuidGenerator()) + private var localSettingsProvider = FakeSettingsProvider(true, null, 100.0) + private var remoteSettingsProvider = FakeSettingsProvider(true, null, 100.0) + private var sessionsSettings = SessionsSettings(localSettingsProvider, remoteSettingsProvider) + + @After + fun cleanUp() { + FirebaseApp.clearInstancesForTest() + } + + @Test + fun initSharedSessionRepo_readFromDatastore() = runTest { + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings = sessionsSettings, + sessionGenerator = sessionGenerator, + sessionFirelogPublisher = publisher, + timeProvider = fakeTimeProvider, + sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + fakeDataStore.close() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_INIT) + } + + @Test + fun initSharedSessionRepo_coldStart() = runTest { + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + backgroundTime = fakeTimeProvider.currentTime(), + processDataMap = mapOf("" to ProcessData(pid = 10, uuid = "uuid")), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings = sessionsSettings, + sessionGenerator = sessionGenerator, + sessionFirelogPublisher = publisher, + timeProvider = fakeTimeProvider, + sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(coldStart = true), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + + sharedSessionRepository.appForeground() + runCurrent() + fakeDataStore.close() + + assertThat(sharedSessionRepository.localSessionData.sessionDetails) + .isEqualTo(SessionDetails(SESSION_ID_1, SESSION_ID_1, 0, fakeTimeProvider.currentTime().us)) + } + + @Test + fun initSharedSessionRepo_initException() = runTest { + val sessionFirelogPublisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ), + IllegalArgumentException("Datastore init failed"), + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + fakeDataStore.close() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_1) + } + + @Test + fun appForegroundGenerateNewSession_updateSuccess() = runTest { + val sessionFirelogPublisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + backgroundTime = fakeTimeProvider.currentTime(), + processDataMap = mapOf("" to ProcessData(pid = 10, uuid = "uuid")), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + + fakeTimeProvider.addInterval(20.hours) + sharedSessionRepository.appForeground() + runCurrent() + + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_1) + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull() + assertThat(sharedSessionRepository.previousNotificationType) + .isEqualTo(SharedSessionRepositoryImpl.NotificationType.GENERAL) + fakeDataStore.close() + } + + @Test + fun appForegroundGenerateNewSession_updateFail() = runTest { + val sessionFirelogPublisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ), + IllegalArgumentException("Datastore init failed"), + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + + // set background time first + fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed")) + sharedSessionRepository.appBackground() + runCurrent() + + // foreground update session + fakeTimeProvider.addInterval(20.hours) + fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed")) + sharedSessionRepository.appForeground() + runCurrent() + + // session_2 here because session_1 is failed when try to init datastore + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_2) + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull() + assertThat(sharedSessionRepository.previousNotificationType) + .isEqualTo(SharedSessionRepositoryImpl.NotificationType.FALLBACK) + fakeDataStore.close() + } + + companion object { + const val SESSION_ID_INIT = "12345678901234546677960" + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/CrashEventReceiverTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/CrashEventReceiverTest.kt new file mode 100644 index 00000000000..7f677bf64a7 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/CrashEventReceiverTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.concurrent.TestOnlyExecutors +import com.google.firebase.sessions.SessionData +import com.google.firebase.sessions.SessionDetails +import com.google.firebase.sessions.SessionFirelogPublisherImpl +import com.google.firebase.sessions.SessionGenerator +import com.google.firebase.sessions.SharedSessionRepositoryImpl +import com.google.firebase.sessions.SharedSessionRepositoryTest.Companion.SESSION_ID_INIT +import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.testing.FakeDataStore +import com.google.firebase.sessions.testing.FakeEventGDTLogger +import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeFirebaseInstallations +import com.google.firebase.sessions.testing.FakeProcessDataManager +import com.google.firebase.sessions.testing.FakeSettingsProvider +import com.google.firebase.sessions.testing.FakeTimeProvider +import com.google.firebase.sessions.testing.FakeUuidGenerator +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class CrashEventReceiverTest { + @Test + fun notifyCrashOccurredOnForegroundOnly() = runTest { + // Setup + val fakeFirebaseApp = FakeFirebaseApp() + val fakeEventGDTLogger = FakeEventGDTLogger() + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD", "FakeAuthToken") + val fakeTimeProvider = FakeTimeProvider() + val sessionGenerator = SessionGenerator(fakeTimeProvider, FakeUuidGenerator()) + val localSettingsProvider = FakeSettingsProvider(true, null, 100.0) + val remoteSettingsProvider = FakeSettingsProvider(true, null, 100.0) + val sessionsSettings = SessionsSettings(localSettingsProvider, remoteSettingsProvider) + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings = sessionsSettings, + sessionGenerator = sessionGenerator, + sessionFirelogPublisher = publisher, + timeProvider = fakeTimeProvider, + sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + CrashEventReceiver.sharedSessionRepository = sharedSessionRepository + + runCurrent() + + // Process starts in the background + assertThat(sharedSessionRepository.isInForeground).isFalse() + + // This will not update background time since the process is already in the background + val originalBackgroundTime = fakeTimeProvider.currentTime() + CrashEventReceiver.notifyCrashOccurred() + assertThat(sharedSessionRepository.localSessionData.backgroundTime) + .isEqualTo(originalBackgroundTime) + + // Wait a bit, then bring the process to foreground + fakeTimeProvider.addInterval(31.minutes) + sharedSessionRepository.appForeground() + + runCurrent() + + // The background time got cleared + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull() + + // Wait a bit, then notify of a crash + fakeTimeProvider.addInterval(3.seconds) + val newBackgroundTime = fakeTimeProvider.currentTime() + CrashEventReceiver.notifyCrashOccurred() + + runCurrent() + + // Verify the background time got updated + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isEqualTo(newBackgroundTime) + + // Clean up + fakeDataStore.close() + FirebaseApp.clearInstancesForTest() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt index f662fe13e90..d9095dbbd7d 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt @@ -23,7 +23,6 @@ import com.google.firebase.sessions.api.SessionSubscriber.Name.MATT_SAYS_HI import com.google.firebase.sessions.testing.FakeSessionSubscriber import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -34,7 +33,6 @@ import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class FirebaseSessionsDependenciesTest { @After diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt index e4fb0b00148..74df328ae57 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt @@ -16,28 +16,20 @@ package com.google.firebase.sessions.settings -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.preferencesDataStoreFile import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.installations.FirebaseInstallationsApi -import com.google.firebase.sessions.ApplicationInfo import com.google.firebase.sessions.SessionEvents import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher -import kotlin.coroutines.CoroutineContext +import com.google.firebase.sessions.testing.FakeSettingsCache +import com.google.firebase.sessions.testing.FakeTimeProvider import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import org.json.JSONObject @@ -45,261 +37,204 @@ import org.junit.After import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class RemoteSettingsTest { @Test - fun remoteSettings_successfulFetchCachesValues() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isNull() - assertThat(remoteSettings.sessionRestartTimeout).isNull() - - fakeFetcher.responseJSONObject = JSONObject(VALID_RESPONSE) - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulFetchCachesValues() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isNull() + assertThat(remoteSettings.sessionRestartTimeout).isNull() + + fakeFetcher.responseJSONObject = JSONObject(VALID_RESPONSE) + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_successfulFetchWithLessConfigsCachesOnlyReceivedValues() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isNull() - assertThat(remoteSettings.sessionRestartTimeout).isNull() - - val fetchedResponse = JSONObject(VALID_RESPONSE) - fetchedResponse.getJSONObject("app_quality").remove("sessions_enabled") - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulFetchWithLessConfigsCachesOnlyReceivedValues() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isNull() + assertThat(remoteSettings.sessionRestartTimeout).isNull() + + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").remove("sessions_enabled") + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_successfulReFetchUpdatesCache() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val fetchedResponse = JSONObject(VALID_RESPONSE) - fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - fetchedResponse.getJSONObject("app_quality").put("sessions_enabled", true) - fetchedResponse.getJSONObject("app_quality").put("sampling_rate", 0.25) - fetchedResponse.getJSONObject("app_quality").put("session_timeout_seconds", 1200) - - // TODO(mrober): Fix these so we don't need to sleep. Maybe use FakeTime? - // Sleep for a second before updating configs - Thread.sleep(2000) - - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isTrue() - assertThat(remoteSettings.samplingRate).isEqualTo(0.25) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulReFetchUpdatesCache() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val fakeTimeProvider = FakeTimeProvider() + + val remoteSettings = + RemoteSettings( + fakeTimeProvider, + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(fakeTimeProvider), + ) + + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + fetchedResponse.getJSONObject("app_quality").put("sessions_enabled", true) + fetchedResponse.getJSONObject("app_quality").put("sampling_rate", 0.25) + fetchedResponse.getJSONObject("app_quality").put("session_timeout_seconds", 1200) + + fakeTimeProvider.addInterval(31.minutes) + + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isTrue() + assertThat(remoteSettings.samplingRate).isEqualTo(0.25) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_successfulFetchWithEmptyConfigRetainsOldConfigs() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val fetchedResponse = JSONObject(VALID_RESPONSE) - fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - fetchedResponse.remove("app_quality") - - // Sleep for a second before updating configs - Thread.sleep(2000) - - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulFetchWithEmptyConfigRetainsOldConfigs() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val fakeTimeProvider = FakeTimeProvider() + + val remoteSettings = + RemoteSettings( + fakeTimeProvider, + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + fakeTimeProvider.addInterval(31.seconds) + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + fetchedResponse.remove("app_quality") + + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_fetchWhileFetchInProgress() = - runTest(UnconfinedTestDispatcher()) { - // This test does: - // 1. Do a fetch with a fake fetcher that will block for 3 seconds. - // 2. While that is happening, do a second fetch. - // - First fetch is still fetching, so second fetch should fall through to the mutex. - // - Second fetch will be blocked until first completes. - // - First fetch returns, should unblock the second fetch. - // - Second fetch should go into mutex, sees cache is valid in "double check," exist early. - // 3. After a fetch completes, do a third fetch. - // - First fetch should have have updated the cache. - // - Third fetch should exit even earlier, never having gone into the mutex. - - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcherWithDelay = - FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE), networkDelay = 3.seconds) - - fakeFetcherWithDelay.responseJSONObject - .getJSONObject("app_quality") - .put("sampling_rate", 0.125) - - val remoteSettingsWithDelay = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcherWithDelay, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - // Do the first fetch. This one should fetched the configsFetcher. - val firstFetch = launch(Dispatchers.Default) { remoteSettingsWithDelay.updateSettings() } - - // Wait a second, and then do the second fetch while first is still running. - // This one should block until the first fetch completes, but then exit early. - launch(Dispatchers.Default) { - delay(1.seconds) - remoteSettingsWithDelay.updateSettings() - } + fun remoteSettings_fetchWhileFetchInProgress() = runTest { + // This test does: + // 1. Do a fetch with a fake fetcher that will block for 3 seconds. + // 2. While that is happening, do a second fetch. + // - First fetch is still fetching, so second fetch should fall through to the mutex. + // - Second fetch will be blocked until first completes. + // - First fetch returns, should unblock the second fetch. + // - Second fetch should go into mutex, sees cache is valid in "double check," exist early. + // 3. After a fetch completes, do a third fetch. + // - First fetch should have have updated the cache. + // - Third fetch should exit even earlier, never having gone into the mutex. + + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcherWithDelay = + FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE), networkDelay = 3.seconds) + + fakeFetcherWithDelay.responseJSONObject.getJSONObject("app_quality").put("sampling_rate", 0.125) + + val remoteSettingsWithDelay = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + configsFetcher = fakeFetcherWithDelay, + FakeSettingsCache(), + ) + + // Do the first fetch. This one should fetched the configsFetcher. + val firstFetch = launch(Dispatchers.Default) { remoteSettingsWithDelay.updateSettings() } + + // Wait a second, and then do the second fetch while first is still running. + // This one should block until the first fetch completes, but then exit early. + launch(Dispatchers.Default) { + delay(1.seconds) + remoteSettingsWithDelay.updateSettings() + } - // Wait until the first fetch is done, then do a third fetch. - // This one should not even block, and exit early. - firstFetch.join() - withTimeout(1.seconds) { remoteSettingsWithDelay.updateSettings() } + // Wait until the first fetch is done, then do a third fetch. + // This one should not even block, and exit early. + firstFetch.join() + withTimeout(1.seconds) { remoteSettingsWithDelay.updateSettings() } - // Assert that the configsFetcher was fetched exactly once. - assertThat(fakeFetcherWithDelay.timesCalled).isEqualTo(1) - assertThat(remoteSettingsWithDelay.samplingRate).isEqualTo(0.125) - } + // Assert that the configsFetcher was fetched exactly once. + assertThat(fakeFetcherWithDelay.timesCalled).isEqualTo(1) + assertThat(remoteSettingsWithDelay.samplingRate).isEqualTo(0.125) + } @After fun cleanUp() { FirebaseApp.clearInstancesForTest() } - internal companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_session_settings_test" - + private companion object { const val VALID_RESPONSE = """ { @@ -318,30 +253,5 @@ class RemoteSettingsTest { } } """ - - /** - * Build an instance of [RemoteSettings] using the Dagger factory. - * - * This is needed because the SDK vendors Dagger to a difference namespace, but it does not for - * these unit tests. The [RemoteSettings.lazySettingsCache] has type [dagger.Lazy] in these - * tests, but type `com.google.firebase.sessions.dagger.Lazy` in the SDK. This method to build - * the instance is the easiest I could find that does not need any reference to [dagger.Lazy] in - * the test code. - */ - fun buildRemoteSettings( - backgroundDispatcher: CoroutineContext, - firebaseInstallationsApi: FirebaseInstallationsApi, - appInfo: ApplicationInfo, - configsFetcher: CrashlyticsSettingsFetcher, - settingsCache: SettingsCache, - ): RemoteSettings = - RemoteSettings_Factory.create( - { backgroundDispatcher }, - { firebaseInstallationsApi }, - { appInfo }, - { configsFetcher }, - { settingsCache }, - ) - .get() } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt index 12f40e7cca8..146857ae7f4 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt @@ -17,22 +17,16 @@ package com.google.firebase.sessions.settings import android.os.Bundle -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.preferencesDataStoreFile import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.sessions.SessionDataStoreConfigs import com.google.firebase.sessions.SessionEvents import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher +import com.google.firebase.sessions.testing.FakeSettingsCache import com.google.firebase.sessions.testing.FakeSettingsProvider +import com.google.firebase.sessions.testing.FakeTimeProvider import kotlin.time.Duration.Companion.minutes -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.After @@ -40,7 +34,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SessionsSettingsTest { @@ -91,147 +84,116 @@ class SessionsSettingsTest { remoteSettings = FakeSettingsProvider(), ) - runCurrent() - assertThat(sessionsSettings.sessionsEnabled).isFalse() assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(30.minutes) } @Test - fun sessionSettings_remoteSettingsOverrideDefaultsWhenPresent() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) - - val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) + fun sessionSettings_remoteSettingsOverrideDefaultsWhenPresent() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val context = firebaseApp.applicationContext + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - sessionsSettings.updateSettings() + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) - runCurrent() + sessionsSettings.updateSettings() - assertThat(sessionsSettings.sessionsEnabled).isFalse() - assertThat(sessionsSettings.samplingRate).isEqualTo(0.75) - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) + assertThat(sessionsSettings.sessionsEnabled).isFalse() + assertThat(sessionsSettings.samplingRate).isEqualTo(0.75) + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) - remoteSettings.clearCachedSettings() - } + remoteSettings.clearCachedSettings() + } @Test - fun sessionSettings_manifestOverridesRemoteSettingsAndDefaultsWhenPresent() = - runTest(UnconfinedTestDispatcher()) { - val metadata = Bundle() - metadata.putBoolean("firebase_sessions_enabled", true) - metadata.putDouble("firebase_sessions_sampling_rate", 0.5) - metadata.putInt("firebase_sessions_sessions_restart_timeout", 180) - val firebaseApp = FakeFirebaseApp(metadata).firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) - - val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) + fun sessionSettings_manifestOverridesRemoteSettingsAndDefaultsWhenPresent() = runTest { + val metadata = Bundle() + metadata.putBoolean("firebase_sessions_enabled", true) + metadata.putDouble("firebase_sessions_sampling_rate", 0.5) + metadata.putInt("firebase_sessions_sessions_restart_timeout", 180) + val firebaseApp = FakeFirebaseApp(metadata).firebaseApp + val context = firebaseApp.applicationContext + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - sessionsSettings.updateSettings() + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) - runCurrent() + sessionsSettings.updateSettings() - assertThat(sessionsSettings.sessionsEnabled).isTrue() - assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(3.minutes) + assertThat(sessionsSettings.sessionsEnabled).isTrue() + assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(3.minutes) - remoteSettings.clearCachedSettings() - } + remoteSettings.clearCachedSettings() + } @Test - fun sessionSettings_invalidManifestConfigsDoNotOverride() = - runTest(UnconfinedTestDispatcher()) { - val metadata = Bundle() - metadata.putBoolean("firebase_sessions_enabled", false) - metadata.putDouble("firebase_sessions_sampling_rate", -0.2) // Invalid - metadata.putInt("firebase_sessions_sessions_restart_timeout", -2) // Invalid - val firebaseApp = FakeFirebaseApp(metadata).firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - val invalidResponse = - VALID_RESPONSE.replace( - "\"sampling_rate\":0.75,", - "\"sampling_rate\":1.2,", // Invalid - ) - fakeFetcher.responseJSONObject = JSONObject(invalidResponse) - - val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) - - sessionsSettings.updateSettings() + fun sessionSettings_invalidManifestConfigsDoNotOverride() = runTest { + val metadata = Bundle() + metadata.putBoolean("firebase_sessions_enabled", false) + metadata.putDouble("firebase_sessions_sampling_rate", -0.2) // Invalid + metadata.putInt("firebase_sessions_sessions_restart_timeout", -2) // Invalid + val firebaseApp = FakeFirebaseApp(metadata).firebaseApp + val context = firebaseApp.applicationContext + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val invalidResponse = + VALID_RESPONSE.replace( + "\"sampling_rate\":0.75,", + "\"sampling_rate\":1.2,", // Invalid + ) + fakeFetcher.responseJSONObject = JSONObject(invalidResponse) + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - runCurrent() + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) - assertThat(sessionsSettings.sessionsEnabled).isFalse() // Manifest - assertThat(sessionsSettings.samplingRate).isEqualTo(1.0) // SDK default - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) // Remote + sessionsSettings.updateSettings() - remoteSettings.clearCachedSettings() - } + assertThat(sessionsSettings.sessionsEnabled).isFalse() // Manifest + assertThat(sessionsSettings.samplingRate).isEqualTo(1.0) // SDK default + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) // Remote - @Test - fun sessionSettings_dataStorePreferencesNameIsFilenameSafe() { - assertThat(SessionDataStoreConfigs.SESSIONS_CONFIG_NAME).matches("^[a-zA-Z0-9_=]+\$") + remoteSettings.clearCachedSettings() } @After @@ -240,8 +202,6 @@ class SessionsSettingsTest { } private companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_session_settings_test" - const val VALID_RESPONSE = """ { diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt index c4d35c86456..a8d8429b5a8 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt @@ -17,13 +17,16 @@ package com.google.firebase.sessions.settings import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeTimeProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test @@ -33,13 +36,24 @@ import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SettingsCacheTest { - private val Context.dataStore: DataStore by - preferencesDataStore(name = SESSION_TEST_CONFIGS_NAME) + private val appContext: Context = ApplicationProvider.getApplicationContext() @Test fun sessionCache_returnsEmptyCache() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + runCurrent() assertThat(settingsCache.sessionSamplingRate()).isNull() assertThat(settingsCache.sessionsEnabled()).isNull() @@ -49,14 +63,28 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCachedValue() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -69,17 +97,40 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsPreviouslyStoredValue() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ) + + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = sessionConfigsDataStore, + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) // Create a new instance to imitate a second app launch. - val newSettingsCache = SettingsCache(context.dataStore) + val newSettingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = sessionConfigsDataStore, + ) + + runCurrent() assertThat(newSettingsCache.sessionsEnabled()).isFalse() assertThat(newSettingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -93,14 +144,28 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCacheExpiredWithShortCacheDuration() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(0) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 0, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() @@ -112,13 +177,28 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCachedValueWithPartialConfigs() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = null, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() @@ -130,25 +210,43 @@ class SettingsCacheTest { @Test fun settingConfigsAllowsUpdateConfigsAndCachesValues() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionRestartTimeout()).isEqualTo(600) assertThat(settingsCache.hasCacheExpired()).isFalse() - settingsCache.updateSettingsEnabled(true) - settingsCache.updateSamplingRate(0.33) - settingsCache.updateSessionRestartTimeout(100) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(0) + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = true, + sessionSamplingRate = 0.33, + sessionTimeoutSeconds = 100, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 0, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.33) assertThat(settingsCache.sessionsEnabled()).isTrue() @@ -160,25 +258,43 @@ class SettingsCacheTest { @Test fun settingConfigsCleansCacheForNullValues() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionRestartTimeout()).isEqualTo(600) assertThat(settingsCache.hasCacheExpired()).isFalse() - settingsCache.updateSettingsEnabled(null) - settingsCache.updateSamplingRate(0.33) - settingsCache.updateSessionRestartTimeout(null) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = null, + sessionSamplingRate = 0.33, + sessionTimeoutSeconds = null, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.33) assertThat(settingsCache.sessionsEnabled()).isNull() @@ -192,8 +308,4 @@ class SettingsCacheTest { fun cleanUp() { FirebaseApp.clearInstancesForTest() } - - private companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_test_session_settings" - } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt new file mode 100644 index 00000000000..1157a309917 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions.testing + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** Fake [DataStore] that can act like an in memory data store, or throw provided exceptions. */ +@OptIn(DelicateCoroutinesApi::class) +internal class FakeDataStore( + private val firstValue: T, + private val firstThrowable: Throwable? = null, +) : DataStore { + // The channel is buffered so data can be updated without blocking until collected + // Default buffer size is 64. This makes unit tests more convenient to write + private val channel = Channel<() -> T>(Channel.BUFFERED) + private var value = firstValue + + private var throwOnUpdateData: Throwable? = null + + override val data: Flow = flow { + // If a first throwable is set, simply throw it + // This is intended to simulate a failure on init + if (firstThrowable != null) { + throw firstThrowable + } + + // Otherwise, emit the first value + emit(firstValue) + + // Start receiving values on the channel, and emit them + // The values are updated by updateData or throwOnNextEmit + try { + while (true) { + // Invoke the lambda in the channel + // Either emit the value, or throw + emit(channel.receive().invoke()) + } + } catch (_: ClosedReceiveChannelException) { + // Expected when the channel is closed + } + } + + override suspend fun updateData(transform: suspend (t: T) -> T): T { + // Check for a throwable to throw on this call to update data + val throwable = throwOnUpdateData + if (throwable != null) { + // Clear the throwable since it should only throw once + throwOnUpdateData = null + throw throwable + } + + // Apply the transformation and send it to the channel + val transformedValue = transform(value) + value = transformedValue + if (!channel.isClosedForSend) { + channel.send { transformedValue } + } + + return transformedValue + } + + /** Set an exception to throw on the next call to [updateData]. */ + fun throwOnNextUpdateData(throwable: Throwable) { + throwOnUpdateData = throwable + } + + /** Set an exception to throw on the next emit. */ + suspend fun throwOnNextEmit(throwable: Throwable) { + if (!channel.isClosedForSend) { + channel.send { throw throwable } + } + } + + /** Finish the test. */ + fun close() { + // Close the channel to stop the flow from emitting more values + // This might be needed if tests fail with UncompletedCoroutinesError + channel.close() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt index eea9114b3b8..e934ada6bf0 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt @@ -29,7 +29,10 @@ import org.robolectric.Shadows import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager -internal class FakeFirebaseApp(metadata: Bundle? = null) { +internal class FakeFirebaseApp( + metadata: Bundle? = null, + processes: List = emptyList(), +) { val firebaseApp: FirebaseApp init { @@ -45,12 +48,16 @@ internal class FakeFirebaseApp(metadata: Bundle? = null) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val shadowActivityManager: ShadowActivityManager = Shadow.extract(activityManager) - val runningAppProcessInfo = ActivityManager.RunningAppProcessInfo() - runningAppProcessInfo.pid = 0 - runningAppProcessInfo.uid = 313 - runningAppProcessInfo.processName = context.packageName - runningAppProcessInfo.importance = 100 - shadowActivityManager.setProcesses(listOf(runningAppProcessInfo)) + if (processes.isEmpty()) { + val runningAppProcessInfo = ActivityManager.RunningAppProcessInfo() + runningAppProcessInfo.pid = 0 + runningAppProcessInfo.uid = 313 + runningAppProcessInfo.processName = context.packageName + runningAppProcessInfo.importance = 100 + shadowActivityManager.setProcesses(listOf(runningAppProcessInfo)) + } else { + shadowActivityManager.setProcesses(processes) + } firebaseApp = Firebase.initialize( @@ -59,7 +66,7 @@ internal class FakeFirebaseApp(metadata: Bundle? = null) { .setApplicationId(MOCK_APP_ID) .setApiKey(MOCK_API_KEY) .setProjectId(MOCK_PROJECT_ID) - .build() + .build(), ) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt deleted file mode 100644 index 2975447bbaa..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions.testing - -import com.google.firebase.sessions.SessionDetails -import com.google.firebase.sessions.SessionFirelogPublisher - -/** - * Fake implementation of [SessionFirelogPublisher] that allows for inspecting the session details - * that were sent to it. - */ -internal class FakeFirelogPublisher : SessionFirelogPublisher { - - /** All the sessions that were uploaded via this fake [SessionFirelogPublisher] */ - val loggedSessions = ArrayList() - - override fun logSession(sessionDetails: SessionDetails) { - loggedSessions.add(sessionDetails) - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt new file mode 100644 index 00000000000..d6e287196d4 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions.testing + +import com.google.firebase.sessions.ProcessData +import com.google.firebase.sessions.ProcessDataManager + +/** + * Fake implementation of ProcessDataManager that returns the provided [coldStart] value for + * [isColdStart], and similar for [isMyProcessStale], until [onSessionGenerated] gets called then + * returns false. + */ +internal class FakeProcessDataManager( + private val coldStart: Boolean = false, + private var myProcessStale: Boolean = coldStart, + override val myProcessName: String = "com.google.firebase.sessions.test", + override var myPid: Int = 0, + override var myUuid: String = FakeUuidGenerator.UUID_1, +) : ProcessDataManager { + private var hasGeneratedSession: Boolean = false + + override fun isColdStart(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + return false + } + + return coldStart + } + + override fun isMyProcessStale(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + return false + } + + return myProcessStale + } + + override fun onSessionGenerated() { + hasGeneratedSession = true + } + + override fun updateProcessDataMap( + processDataMap: Map? + ): Map = processDataMap ?: emptyMap() +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt similarity index 54% rename from firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt rename to firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt index f98852032c8..1afebb2d0bb 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,19 @@ package com.google.firebase.sessions.testing -import com.google.firebase.sessions.SessionDatastore +import android.app.ActivityManager -/** - * Fake implementaiton of the [SessionDatastore] that allows for inspecting and modifying the - * currently stored values in unit tests. - */ -internal class FakeSessionDatastore : SessionDatastore { - - /** The currently stored value */ - private var currentSessionId: String? = null - - override fun updateSessionId(sessionId: String) { - currentSessionId = sessionId +/** Fake [ActivityManager.RunningAppProcessInfo] that is easy to construct. */ +internal class FakeRunningAppProcessInfo( + pid: Int = 0, + uid: Int = 313, + processName: String = "fake.process.name", + importance: Int = 100, +) : ActivityManager.RunningAppProcessInfo() { + init { + this.pid = pid + this.uid = uid + this.processName = processName + this.importance = importance } - - override fun getCurrentSessionId() = currentSessionId } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt deleted file mode 100644 index 0d4e58e2014..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions.testing - -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.os.Messenger -import com.google.firebase.sessions.SessionLifecycleService -import com.google.firebase.sessions.SessionLifecycleServiceBinder -import java.util.concurrent.LinkedBlockingQueue -import org.robolectric.Shadows.shadowOf - -/** - * Fake implementation of the [SessionLifecycleServiceBinder] that allows for inspecting the - * callbacks and received messages of the service in unit tests. - */ -internal class FakeSessionLifecycleServiceBinder : SessionLifecycleServiceBinder { - - val clientCallbacks = mutableListOf() - val connectionCallbacks = mutableListOf() - val receivedMessageCodes = LinkedBlockingQueue() - var service = Messenger(FakeServiceHandler()) - - internal inner class FakeServiceHandler() : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - receivedMessageCodes.add(msg.what) - } - } - - override fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) { - clientCallbacks.add(callback) - connectionCallbacks.add(serviceConnection) - } - - fun serviceConnected() { - connectionCallbacks.forEach { it.onServiceConnected(componentName, service.getBinder()) } - } - - fun serviceDisconnected() { - connectionCallbacks.forEach { it.onServiceDisconnected(componentName) } - } - - fun broadcastSession(sessionId: String) { - clientCallbacks.forEach { client -> - val msgData = - Bundle().also { it.putString(SessionLifecycleService.SESSION_UPDATE_EXTRA, sessionId) } - client.send( - Message.obtain(null, SessionLifecycleService.SESSION_UPDATED, 0, 0).also { - it.data = msgData - } - ) - } - } - - fun waitForAllMessages() { - shadowOf(Looper.getMainLooper()).idle() - } - - fun clearForTest() { - clientCallbacks.clear() - connectionCallbacks.clear() - receivedMessageCodes.clear() - service = Messenger(FakeServiceHandler()) - } - - companion object { - val componentName = - ComponentName("com.google.firebase.sessions.testing", "FakeSessionLifecycleServiceBinder") - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt new file mode 100644 index 00000000000..2c58ef22d7d --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.sessions.testing + +import com.google.firebase.sessions.TimeProvider +import com.google.firebase.sessions.settings.SessionConfigs +import com.google.firebase.sessions.settings.SessionConfigsSerializer +import com.google.firebase.sessions.settings.SettingsCache + +/** Fake implementation of [SettingsCache]. */ +internal class FakeSettingsCache( + private val timeProvider: TimeProvider = FakeTimeProvider(), + private var sessionConfigs: SessionConfigs = SessionConfigsSerializer.defaultValue, +) : SettingsCache { + override fun hasCacheExpired(): Boolean { + val cacheUpdatedTimeSeconds = sessionConfigs.cacheUpdatedTimeSeconds + val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds + + if (cacheUpdatedTimeSeconds != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = timeProvider.currentTime().seconds - cacheUpdatedTimeSeconds + if (timeDifferenceSeconds < cacheDurationSeconds) { + return false + } + } + + return true + } + + override fun sessionsEnabled(): Boolean? = sessionConfigs.sessionsEnabled + + override fun sessionSamplingRate(): Double? = sessionConfigs.sessionSamplingRate + + override fun sessionRestartTimeout(): Int? = sessionConfigs.sessionTimeoutSeconds + + override suspend fun updateConfigs(sessionConfigs: SessionConfigs) { + this.sessionConfigs = sessionConfigs + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt index 35010de415a..295600cf48e 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt @@ -16,17 +16,19 @@ package com.google.firebase.sessions.testing +import com.google.firebase.sessions.Time import com.google.firebase.sessions.TimeProvider -import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP_US +import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP import kotlin.time.Duration -import kotlin.time.DurationUnit +import kotlin.time.DurationUnit.MILLISECONDS /** * Fake [TimeProvider] that allows programmatically elapsing time forward. * * Default [elapsedRealtime] is [Duration.ZERO] until the time is moved using [addInterval]. */ -class FakeTimeProvider(private val initialTimeUs: Long = TEST_SESSION_TIMESTAMP_US) : TimeProvider { +internal class FakeTimeProvider(private val initialTime: Time = TEST_SESSION_TIMESTAMP) : + TimeProvider { private var elapsed = Duration.ZERO fun addInterval(interval: Duration) { @@ -38,5 +40,5 @@ class FakeTimeProvider(private val initialTimeUs: Long = TEST_SESSION_TIMESTAMP_ override fun elapsedRealtime(): Duration = elapsed - override fun currentTimeUs(): Long = initialTimeUs + elapsed.toLong(DurationUnit.MICROSECONDS) + override fun currentTime(): Time = Time(ms = initialTime.ms + elapsed.toLong(MILLISECONDS)) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt index da1d273a33f..d5f7dd8f510 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt @@ -16,7 +16,12 @@ package com.google.firebase.sessions.testing -import com.google.android.datatransport.* +import com.google.android.datatransport.Encoding +import com.google.android.datatransport.Event +import com.google.android.datatransport.Transformer +import com.google.android.datatransport.Transport +import com.google.android.datatransport.TransportFactory +import com.google.android.datatransport.TransportScheduleCallback import com.google.firebase.sessions.SessionEvent /** Fake [Transport] that implements [send]. */ @@ -34,7 +39,7 @@ internal class FakeTransport() : Transport { } /** Fake [TransportFactory] that implements [getTransport]. */ -internal class FakeTransportFactory() : TransportFactory { +internal class FakeTransportFactory : TransportFactory { var name: String? = null var payloadEncoding: Encoding? = null @@ -42,9 +47,9 @@ internal class FakeTransportFactory() : TransportFactory { override fun getTransport( name: String?, - payloadType: java.lang.Class?, + payloadType: Class?, payloadEncoding: Encoding?, - payloadTransformer: Transformer? + payloadTransformer: Transformer?, ): Transport? { this.name = name this.payloadEncoding = payloadEncoding @@ -54,11 +59,14 @@ internal class FakeTransportFactory() : TransportFactory { return fakeTransport } - @Deprecated("This is deprecated in the API. Don't use or expect on this function.") + @Deprecated( + "This is deprecated in the API. Don't use or expect on this function.", + ReplaceWith("null"), + ) override fun getTransport( name: String?, - payloadType: java.lang.Class?, - payloadTransformer: Transformer? + payloadType: Class?, + payloadTransformer: Transformer?, ): Transport? { return null } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt index 88f1f816c12..5fb2cd47785 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt @@ -24,6 +24,8 @@ internal class FakeUuidGenerator(private val names: List = listOf(UUID_1 UuidGenerator { private var index = -1 + constructor(vararg names: String) : this(names.toList()) + override fun next(): UUID { index = (index + 1).coerceAtMost(names.size - 1) return UUID.fromString(names[index]) diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt deleted file mode 100644 index b3431f71840..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2025 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. - */ - -package com.google.firebase.sessions.testing - -import com.google.firebase.Firebase -import com.google.firebase.app -import com.google.firebase.sessions.FirebaseSessions -import com.google.firebase.sessions.FirebaseSessionsComponent -import com.google.firebase.sessions.SessionDatastore -import com.google.firebase.sessions.SessionFirelogPublisher -import com.google.firebase.sessions.SessionGenerator -import com.google.firebase.sessions.SessionLifecycleServiceBinder -import com.google.firebase.sessions.settings.SessionsSettings -import com.google.firebase.sessions.settings.SettingsProvider - -/** Fake component to manage [FirebaseSessions] and related, often faked, dependencies. */ -@Suppress("MemberVisibilityCanBePrivate") // Keep access to fakes open for convenience -internal class FirebaseSessionsFakeComponent : FirebaseSessionsComponent { - // TODO(mrober): Move tests that need DI to integration tests, and remove this component. - - // Fakes, access these instances to setup test cases, e.g., add interval to fake time provider. - val fakeTimeProvider = FakeTimeProvider() - val fakeUuidGenerator = FakeUuidGenerator() - val fakeSessionDatastore = FakeSessionDatastore() - val fakeFirelogPublisher = FakeFirelogPublisher() - val fakeSessionLifecycleServiceBinder = FakeSessionLifecycleServiceBinder() - - // Settings providers, default to fake, set these to real instances for relevant test cases. - var localOverrideSettings: SettingsProvider = FakeSettingsProvider() - var remoteSettings: SettingsProvider = FakeSettingsProvider() - - override val firebaseSessions: FirebaseSessions - get() = throw NotImplementedError("FirebaseSessions not implemented, use integration tests.") - - override val sessionDatastore: SessionDatastore = fakeSessionDatastore - - override val sessionFirelogPublisher: SessionFirelogPublisher = fakeFirelogPublisher - - override val sessionGenerator: SessionGenerator by lazy { - SessionGenerator(timeProvider = fakeTimeProvider, uuidGenerator = fakeUuidGenerator) - } - - override val sessionsSettings: SessionsSettings by lazy { - SessionsSettings(localOverrideSettings, remoteSettings) - } - - val sessionLifecycleServiceBinder: SessionLifecycleServiceBinder - get() = fakeSessionLifecycleServiceBinder - - companion object { - val instance: FirebaseSessionsFakeComponent - get() = Firebase.app[FirebaseSessionsFakeComponent::class.java] - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt deleted file mode 100644 index 8dc6454931e..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2023 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. - */ - -package com.google.firebase.sessions.testing - -import com.google.firebase.components.Component -import com.google.firebase.components.ComponentRegistrar -import com.google.firebase.components.Dependency -import com.google.firebase.components.Qualified.unqualified -import com.google.firebase.platforminfo.LibraryVersionComponent -import com.google.firebase.sessions.BuildConfig -import com.google.firebase.sessions.FirebaseSessions -import com.google.firebase.sessions.FirebaseSessionsComponent - -/** - * [ComponentRegistrar] for setting up Fake components for [FirebaseSessions] and its internal - * dependencies for unit tests. - */ -internal class FirebaseSessionsFakeRegistrar : ComponentRegistrar { - override fun getComponents() = - listOf( - Component.builder(FirebaseSessionsComponent::class.java) - .name("fire-sessions-component") - .add(Dependency.required(firebaseSessionsFakeComponent)) - .factory { container -> container.get(firebaseSessionsFakeComponent) } - .build(), - Component.builder(FirebaseSessionsFakeComponent::class.java) - .name("fire-sessions-fake-component") - .factory { FirebaseSessionsFakeComponent() } - .build(), - LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), - ) - - private companion object { - const val LIBRARY_NAME = "fire-sessions" - - val firebaseSessionsFakeComponent = unqualified(FirebaseSessionsFakeComponent::class.java) - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt index 7619bc12588..105950a37f4 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt @@ -30,23 +30,24 @@ import com.google.firebase.sessions.ProcessDetails import com.google.firebase.sessions.SessionDetails import com.google.firebase.sessions.SessionEvent import com.google.firebase.sessions.SessionInfo +import com.google.firebase.sessions.Time internal object TestSessionEventData { - const val TEST_SESSION_TIMESTAMP_US: Long = 12340000 + val TEST_SESSION_TIMESTAMP: Time = Time(ms = 12340) val TEST_SESSION_DETAILS = SessionDetails( sessionId = "a1b2c3", firstSessionId = "a1a1a1", sessionIndex = 3, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) val TEST_DATA_COLLECTION_STATUS = DataCollectionStatus( performance = DataCollectionState.COLLECTION_SDK_NOT_INSTALLED, crashlytics = DataCollectionState.COLLECTION_SDK_NOT_INSTALLED, - sessionSamplingRate = 1.0 + sessionSamplingRate = 1.0, ) val TEST_SESSION_DATA = @@ -54,19 +55,14 @@ internal object TestSessionEventData { sessionId = "a1b2c3", firstSessionId = "a1a1a1", sessionIndex = 3, - eventTimestampUs = TEST_SESSION_TIMESTAMP_US, + eventTimestampUs = TEST_SESSION_TIMESTAMP.us, dataCollectionStatus = TEST_DATA_COLLECTION_STATUS, firebaseInstallationId = "", firebaseAuthenticationToken = "", ) val TEST_PROCESS_DETAILS = - ProcessDetails( - processName = "com.google.firebase.sessions.test", - 0, - 100, - false, - ) + ProcessDetails(processName = "com.google.firebase.sessions.test", 0, 100, false) val TEST_APP_PROCESS_DETAILS = listOf(TEST_PROCESS_DETAILS) diff --git a/firebase-sessions/test-app/src/main/AndroidManifest.xml b/firebase-sessions/test-app/src/main/AndroidManifest.xml index 9965842a01e..8d2011ca18e 100644 --- a/firebase-sessions/test-app/src/main/AndroidManifest.xml +++ b/firebase-sessions/test-app/src/main/AndroidManifest.xml @@ -45,8 +45,8 @@ + android:name="sessions_sampling_percentage" + android:value="0.01" /> + + + + = Build.VERSION_CODES.P) Application.getProcessName() else "unknown" - private fun logProcessDetails() { val pid = android.os.Process.myPid() val uid = android.os.Process.myUid() val activity = javaClass.name - val process = getProcessName() - Log.i(TAG, "activity: $activity process: $process, pid: $pid, uid: $uid") + Log.i(TAG, "activity: $activity process: $myProcessName, pid: $pid, uid: $uid") } private fun logFirebaseDetails() { @@ -85,15 +80,11 @@ open class BaseActivity : AppCompatActivity() { val defaultFirebaseApp = FirebaseApp.getInstance() Log.i( TAG, - "activity: $activity firebase: ${defaultFirebaseApp.name} appsCount: ${firebaseApps.count()}" + "activity: $activity firebase: ${defaultFirebaseApp.name} appsCount: ${firebaseApps.count()}", ) } private fun setProcessAttribute() { - FirebasePerformance.getInstance().putAttribute("process_name", getProcessName()) - } - - companion object { - val TAG = "BaseActivity" + FirebasePerformance.getInstance().putAttribute("process_name", myProcessName) } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt index 89d2f03f1ce..203ab0416d1 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt @@ -21,6 +21,7 @@ import android.content.Context import android.content.Intent import android.util.Log import android.widget.Toast +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG class CrashBroadcastReceiver : BroadcastReceiver() { @@ -42,7 +43,6 @@ class CrashBroadcastReceiver : BroadcastReceiver() { } companion object { - val TAG = "CrashBroadcastReceiver" val CRASH_ACTION = "com.google.firebase.testing.sessions.CrashBroadcastReceiver.CRASH_ACTION" val TOAST_ACTION = "com.google.firebase.testing.sessions.CrashBroadcastReceiver.TOAST_ACTION" } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt index f5a965da7d4..ebe54414661 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt @@ -16,7 +16,6 @@ package com.google.firebase.testing.sessions -import android.app.Application import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT import android.content.Intent.FLAG_ACTIVITY_NEW_TASK @@ -30,7 +29,11 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.perf.FirebasePerformance +import com.google.firebase.perf.trace +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName import com.google.firebase.testing.sessions.databinding.FragmentFirstBinding +import java.net.HttpURLConnection +import java.net.URL import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers @@ -52,7 +55,7 @@ class FirstFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View? { _binding = FragmentFirstBinding.inflate(inflater, container, false) @@ -79,6 +82,28 @@ class FirstFragment : Fragment() { performanceTrace.stop() } } + binding.createTrace2.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val performanceTrace = performance.newTrace("test_trace_2") + performanceTrace.start() + delay(1200) + performanceTrace.stop() + } + } + binding.createNetworkTrace.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val url = URL("https://www.google.com") + val metric = + performance.newHttpMetric("https://www.google.com", FirebasePerformance.HttpMethod.GET) + metric.trace { + val conn = url.openConnection() as HttpURLConnection + val content = conn.inputStream.bufferedReader().use { it.readText() } + setHttpResponseCode(conn.responseCode) + setResponsePayloadSize(content.length.toLong()) + conn.disconnect() + } + } + } binding.buttonForegroundProcess.setOnClickListener { if (binding.buttonForegroundProcess.getText().startsWith("Start")) { ForegroundService.startService(requireContext(), "Starting service at ${getDateText()}") @@ -104,7 +129,7 @@ class FirstFragment : Fragment() { intent.addFlags(FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } - binding.processName.text = getProcessName() + binding.processName.text = myProcessName } override fun onResume() { @@ -127,9 +152,5 @@ class FirstFragment : Fragment() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) else "unknown" - - fun getProcessName(): String = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Application.getProcessName() - else "unknown" } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt index f616a0a54a4..a17511c4740 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt @@ -29,6 +29,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.google.firebase.FirebaseApp +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG class ForegroundService : Service() { private val CHANNEL_ID = "CrashForegroundService" @@ -104,10 +105,8 @@ class ForegroundService : Service() { } companion object { - val TAG = "WidgetForegroundService" - fun startService(context: Context, message: String) { - Log.i(TAG, "Starting foreground serice") + Log.i(TAG, "Starting foreground service") ContextCompat.startForegroundService( context, Intent(context, ForegroundService::class.java).putExtra("inputExtra", message), @@ -115,7 +114,7 @@ class ForegroundService : Service() { } fun stopService(context: Context) { - Log.i(TAG, "Stopping serice") + Log.i(TAG, "Stopping service") context.stopService(Intent(context, ForegroundService::class.java)) } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceA.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceA.kt new file mode 100644 index 00000000000..499a91013f3 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceA.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.testing.sessions + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName +import kotlin.system.exitProcess + +class MyServiceA : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Service A action: ${intent?.action} on process: $myProcessName") + + // Send actions from adb shell this way, so it can start the process if needed: + // am startservice --user 0 -n com.google.firebase.testing.sessions/.MyServiceA -a PING + when (intent?.action) { + "PING" -> ping() + "CRASH" -> crash() + "KILL" -> kill() + "SESSION" -> session() + } + + return START_STICKY + } + + private fun ping() { + repeat(7) { Log.i(TAG, "*** pong ***") } + } + + private fun crash() { + Log.i(TAG, "crashing") + throw IndexOutOfBoundsException("crash service a") + } + + private fun kill() { + Log.i(TAG, "killing process $myProcessName") + exitProcess(0) + } + + private fun session() { + Log.i( + TAG, + "service a, session id: ${TestApplication.sessionSubscriber.sessionDetails?.sessionId}", + ) + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceB.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceB.kt new file mode 100644 index 00000000000..cb9791796b0 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceB.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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. + */ + +package com.google.firebase.testing.sessions + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName +import kotlin.system.exitProcess + +class MyServiceB : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Service B action: ${intent?.action} on process: $myProcessName") + + when (intent?.action) { + "PING" -> ping() + "CRASH" -> crash() + "KILL" -> kill() + } + + return START_STICKY + } + + private fun ping() { + repeat(7) { Log.i(TAG, "*** hello ***") } + Log.i(TAG, "session id: ${TestApplication.sessionSubscriber.sessionDetails?.sessionId}") + } + + private fun crash() { + Log.i(TAG, "crashing") + throw IllegalStateException("crash in service b") + } + + private fun kill() { + Log.i(TAG, "killing process $myProcessName") + exitProcess(0) + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt index 6c2fd3c06b0..434ff1dec08 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt @@ -25,6 +25,7 @@ import android.widget.Button import android.widget.TextView import androidx.lifecycle.lifecycleScope import com.google.firebase.perf.FirebasePerformance +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -56,7 +57,7 @@ class SecondActivity : BaseActivity() { .killBackgroundProcesses("com.google.firebase.testing.sessions") } } - findViewById(R.id.process_name_second).text = getProcessName() + findViewById(R.id.process_name_second).text = myProcessName } override fun onResume() { diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt index 10a95261fa8..f8b8dec2cc7 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt @@ -17,21 +17,29 @@ package com.google.firebase.testing.sessions import android.annotation.SuppressLint +import android.app.Application import android.content.IntentFilter import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Log import android.widget.TextView import androidx.multidex.MultiDexApplication +import com.google.firebase.FirebaseApp import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.api.SessionSubscriber +import java.io.File class TestApplication : MultiDexApplication() { private val broadcastReceiver = CrashBroadcastReceiver() + @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.i(TAG, "TestApplication created on process: $myProcessName") + FirebaseApp.initializeApp(this) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver( broadcastReceiver, IntentFilter(CrashBroadcastReceiver.CRASH_ACTION), @@ -48,6 +56,7 @@ class TestApplication : MultiDexApplication() { } } + @SuppressLint("DiscouragedApi") class FakeSessionSubscriber : SessionSubscriber { override val isDataCollectionEnabled = true override val sessionSubscriberName = SessionSubscriber.Name.MATT_SAYS_HI @@ -78,8 +87,19 @@ class TestApplication : MultiDexApplication() { @SuppressLint("DiscouragedApi") companion object { + const val TAG = "SessionsTestApp" + val sessionSubscriber = FakeSessionSubscriber() + val myProcessName: String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Application.getProcessName() + else + try { + File("/proc/self/cmdline").readText().substringBefore('\u0000').trim() + } catch (_: Exception) { + null + } ?: "unknown" + init { FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.MATT_SAYS_HI) FirebaseSessionsDependencies.register(sessionSubscriber) diff --git a/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml b/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml index af08e7e317e..b40bee65a09 100644 --- a/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml +++ b/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml @@ -55,13 +55,30 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@id/button_anr" /> +