diff --git a/buildSrc/src/main/java/com/google/firebase/gradle/plugins/FirebaseLibraryPlugin.kt b/buildSrc/src/main/java/com/google/firebase/gradle/plugins/FirebaseLibraryPlugin.kt index d6f079ba25f..d27bf28322b 100644 --- a/buildSrc/src/main/java/com/google/firebase/gradle/plugins/FirebaseLibraryPlugin.kt +++ b/buildSrc/src/main/java/com/google/firebase/gradle/plugins/FirebaseLibraryPlugin.kt @@ -85,7 +85,7 @@ class FirebaseLibraryPlugin : BaseFirebaseLibraryPlugin() { android.testServer(FirebaseTestServer(project, firebaseLibrary.testLab, android)) setupStaticAnalysis(project, firebaseLibrary) getIsPomValidTask(project, firebaseLibrary) - setupVersionCheckTasks(project, firebaseLibrary) + // setupVersionCheckTasks(project, firebaseLibrary) configurePublishing(project, firebaseLibrary, android) } diff --git a/firebase-common/src/main/java/com/google/firebase/processinfo/ProcessDetails.java b/firebase-common/src/main/java/com/google/firebase/processinfo/ProcessDetails.java new file mode 100644 index 00000000000..ec3e5df9695 --- /dev/null +++ b/firebase-common/src/main/java/com/google/firebase/processinfo/ProcessDetails.java @@ -0,0 +1,42 @@ +package com.google.firebase.processinfo; + +import androidx.annotation.NonNull; + +import com.google.auto.value.AutoValue; + +@AutoValue +public abstract class ProcessDetails { + @NonNull + public abstract String getProcessName(); + + public abstract int getPid(); + + public abstract int getImportance(); + + public abstract boolean isDefaultProcess(); + + @NonNull + public static Builder builder() { + return new AutoValue_ProcessDetails + .Builder(); + } + + /** Builder for {@link ProcessDetails}. */ + @AutoValue.Builder + public abstract static class Builder { + @NonNull + public abstract Builder setProcessName(@NonNull String processName); + + @NonNull + public abstract Builder setPid(int pid); + + @NonNull + public abstract Builder setImportance(int importance); + + @NonNull + public abstract Builder setDefaultProcess(boolean isDefaultProcess); + + @NonNull + public abstract ProcessDetails build(); + } +} diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt b/firebase-common/src/main/java/com/google/firebase/processinfo/ProcessDetailsProvider.kt similarity index 92% rename from firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt rename to firebase-common/src/main/java/com/google/firebase/processinfo/ProcessDetailsProvider.kt index bfa84d77af0..27e50676d9c 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/ProcessDetailsProvider.kt +++ b/firebase-common/src/main/java/com/google/firebase/processinfo/ProcessDetailsProvider.kt @@ -14,20 +14,19 @@ * limitations under the License. */ -package com.google.firebase.crashlytics.internal +package com.google.firebase.processinfo import android.app.ActivityManager import android.content.Context import android.os.Build import android.os.Process -import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.ProcessDetails /** * Provider of ProcessDetails. * * @hide */ -internal object ProcessDetailsProvider { +object ProcessDetailsProvider { /** Gets the details of all running app processes. */ fun getAppProcessDetails(context: Context): List { val defaultProcessName = context.applicationInfo.processName diff --git a/firebase-crashlytics/firebase-crashlytics.gradle b/firebase-crashlytics/firebase-crashlytics.gradle index 57e245f639c..0162a64f4db 100644 --- a/firebase-crashlytics/firebase-crashlytics.gradle +++ b/firebase-crashlytics/firebase-crashlytics.gradle @@ -88,11 +88,11 @@ dependencies { exclude group: 'com.google.firebase', module: 'firebase-components' } implementation(libs.androidx.annotation) - implementation("com.google.firebase:firebase-common:20.4.2") - implementation("com.google.firebase:firebase-common-ktx:20.4.2") + implementation("com.google.firebase:firebase-common:20.4.3") + implementation("com.google.firebase:firebase-common-ktx:20.4.3") implementation("com.google.firebase:firebase-components:17.1.3") implementation("com.google.firebase:firebase-installations:17.2.0") - implementation("com.google.firebase:firebase-sessions:1.1.0") { + implementation(project(':firebase-sessions')) { exclude group: 'com.google.firebase', module: 'firebase-common' exclude group: 'com.google.firebase', module: 'firebase-components' } diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistenceTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistenceTest.java index 71ef2995558..8138d0d9cc3 100644 --- a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistenceTest.java +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistenceTest.java @@ -31,10 +31,11 @@ import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.Execution; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.Execution.Signal; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.Execution.Thread.Frame; -import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.ProcessDetails; import com.google.firebase.crashlytics.internal.settings.Settings; import com.google.firebase.crashlytics.internal.settings.Settings.FeatureFlagData; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; +import com.google.firebase.processinfo.ProcessDetails; + import java.io.IOException; import java.text.DecimalFormat; import java.util.ArrayList; diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/CrashlyticsRegistrar.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/CrashlyticsRegistrar.java index 708beec5224..c34b87f7790 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/CrashlyticsRegistrar.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/CrashlyticsRegistrar.java @@ -69,6 +69,6 @@ private FirebaseCrashlytics buildCrashlytics(ComponentContainer container) { FirebaseSessions firebaseSessions = container.get(FirebaseSessions.class); return FirebaseCrashlytics.init( - app, firebaseInstallations, firebaseSessions, nativeComponent, analyticsConnector); + app, firebaseInstallations, nativeComponent, analyticsConnector); } } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java index 4c28ef35024..2dde4a3a6cf 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java @@ -41,7 +41,7 @@ import com.google.firebase.crashlytics.internal.settings.SettingsController; import com.google.firebase.inject.Deferred; import com.google.firebase.installations.FirebaseInstallationsApi; -import com.google.firebase.sessions.FirebaseSessions; +import com.google.firebase.sessions.api.FirebaseSessionsDependencies; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -64,7 +64,6 @@ public class FirebaseCrashlytics { static @Nullable FirebaseCrashlytics init( @NonNull FirebaseApp app, @NonNull FirebaseInstallationsApi firebaseInstallationsApi, - @NonNull FirebaseSessions firebaseSessions, @NonNull Deferred nativeComponent, @NonNull Deferred analyticsConnector) { @@ -93,7 +92,7 @@ public class FirebaseCrashlytics { CrashlyticsAppQualitySessionsSubscriber sessionsSubscriber = new CrashlyticsAppQualitySessionsSubscriber(arbiter, fileStore); - firebaseSessions.register(sessionsSubscriber); + FirebaseSessionsDependencies.register(sessionsSubscriber); final CrashlyticsCore core = new CrashlyticsCore( diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsReportDataCapture.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsReportDataCapture.java index b8ad6c967de..c914f8c9d5c 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsReportDataCapture.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsReportDataCapture.java @@ -25,16 +25,17 @@ import android.os.StatFs; import android.text.TextUtils; import com.google.firebase.crashlytics.BuildConfig; -import com.google.firebase.crashlytics.internal.ProcessDetailsProvider; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Architecture; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.Execution; import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.Execution.BinaryImage; -import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event.Application.ProcessDetails; import com.google.firebase.crashlytics.internal.settings.SettingsProvider; import com.google.firebase.crashlytics.internal.stacktrace.StackTraceTrimmingStrategy; import com.google.firebase.crashlytics.internal.stacktrace.TrimmedThrowableData; +import com.google.firebase.processinfo.ProcessDetails; +import com.google.firebase.processinfo.ProcessDetailsProvider; + import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java index 6d969d05c6f..4ba79420914 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java @@ -23,6 +23,8 @@ import com.google.firebase.encoders.annotations.Encodable; import com.google.firebase.encoders.annotations.Encodable.Field; import com.google.firebase.encoders.annotations.Encodable.Ignore; +import com.google.firebase.processinfo.ProcessDetails; + import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.nio.charset.Charset; @@ -962,43 +964,6 @@ public abstract static class Builder { } } - @AutoValue - public abstract static class ProcessDetails { - @NonNull - public abstract String getProcessName(); - - public abstract int getPid(); - - public abstract int getImportance(); - - public abstract boolean isDefaultProcess(); - - @NonNull - public static Builder builder() { - return new AutoValue_CrashlyticsReport_Session_Event_Application_ProcessDetails - .Builder(); - } - - /** Builder for {@link ProcessDetails}. */ - @AutoValue.Builder - public abstract static class Builder { - @NonNull - public abstract Builder setProcessName(@NonNull String processName); - - @NonNull - public abstract Builder setPid(int pid); - - @NonNull - public abstract Builder setImportance(int importance); - - @NonNull - public abstract Builder setDefaultProcess(boolean isDefaultProcess); - - @NonNull - public abstract ProcessDetails build(); - } - } - /** Builder for {@link Application}. */ @AutoValue.Builder public abstract static class Builder { diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java index 5365d747f07..514559a7153 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java @@ -24,6 +24,8 @@ import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event; import com.google.firebase.encoders.DataEncoder; import com.google.firebase.encoders.json.JsonDataEncoderBuilder; +import com.google.firebase.processinfo.ProcessDetails; + import java.io.IOException; import java.io.StringReader; import java.util.ArrayList; @@ -497,9 +499,9 @@ private static Event.Application parseEventApp(@NonNull JsonReader jsonReader) } @NonNull - private static Event.Application.ProcessDetails parseProcessDetails( + private static ProcessDetails parseProcessDetails( @NonNull JsonReader jsonReader) throws IOException { - Event.Application.ProcessDetails.Builder builder = Event.Application.ProcessDetails.builder(); + ProcessDetails.Builder builder = ProcessDetails.builder(); jsonReader.beginObject(); while (jsonReader.hasNext()) { diff --git a/firebase-perf/CHANGELOG.md b/firebase-perf/CHANGELOG.md index 841db1ee053..6ed2fb1827a 100644 --- a/firebase-perf/CHANGELOG.md +++ b/firebase-perf/CHANGELOG.md @@ -1,23 +1,18 @@ # Unreleased - +* [changed] Make Fireperf generate its own session Id. # 20.5.0 * [changed] Added Kotlin extensions (KTX) APIs from `com.google.firebase:firebase-perf-ktx` to `com.google.firebase:firebase-perf` under the `com.google.firebase.perf` package. For details, see the [FAQ about this initiative](https://firebase.google.com/docs/android/kotlin-migration) + * [deprecated] All the APIs from `com.google.firebase:firebase-perf-ktx` have been added to `com.google.firebase:firebase-perf` under the `com.google.firebase.perf` package, and all the Kotlin extensions (KTX) APIs in `com.google.firebase:firebase-perf-ktx` are now deprecated. As early as April 2024, we'll no longer release KTX modules. For details, see the [FAQ about this initiative](https://firebase.google.com/docs/android/kotlin-migration) - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-performance` library. The Kotlin extensions library has no additional -updates. - # 20.4.1 * [changed] Updated `firebase-sessions` dependency to v1.0.2 * [fixed] Make fireperf data collection state is reliable for Firebase Sessions library. @@ -369,4 +364,3 @@ updates. # 16.1.0 * [fixed] Fixed a `SecurityException` crash on certain devices that do not have Google Play Services on them. - diff --git a/firebase-perf/firebase-perf.gradle b/firebase-perf/firebase-perf.gradle index 1c4756afb26..c1e6216837a 100644 --- a/firebase-perf/firebase-perf.gradle +++ b/firebase-perf/firebase-perf.gradle @@ -114,7 +114,7 @@ dependencies { implementation("com.google.firebase:firebase-components:17.1.3") implementation("com.google.firebase:firebase-config:21.5.0") implementation("com.google.firebase:firebase-installations:17.2.0") - implementation("com.google.firebase:firebase-sessions:1.1.0") { + implementation(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/FirebasePerfEarly.java b/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfEarly.java index 3d3e4555061..5b89deaad82 100644 --- a/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfEarly.java +++ b/firebase-perf/src/main/java/com/google/firebase/perf/FirebasePerfEarly.java @@ -15,17 +15,13 @@ package com.google.firebase.perf; import android.content.Context; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.firebase.FirebaseApp; import com.google.firebase.StartupTime; import com.google.firebase.perf.application.AppStateMonitor; import com.google.firebase.perf.config.ConfigResolver; import com.google.firebase.perf.metrics.AppStartTrace; -import com.google.firebase.perf.session.PerfSession; import com.google.firebase.perf.session.SessionManager; -import com.google.firebase.sessions.FirebaseSessions; -import com.google.firebase.sessions.api.SessionSubscriber; import java.util.concurrent.Executor; /** @@ -38,10 +34,7 @@ public class FirebasePerfEarly { public FirebasePerfEarly( - FirebaseApp app, - FirebaseSessions firebaseSessions, - @Nullable StartupTime startupTime, - Executor uiExecutor) { + FirebaseApp app, @Nullable StartupTime startupTime, Executor uiExecutor) { Context context = app.getApplicationContext(); // Initialize ConfigResolver early for accessing device caching layer. @@ -58,31 +51,7 @@ public FirebasePerfEarly( uiExecutor.execute(new AppStartTrace.StartFromBackgroundRunnable(appStartTrace)); } - // Register with Firebase sessions to receive updates about session changes. - firebaseSessions.register( - new SessionSubscriber() { - @Override - public void onSessionChanged(@NonNull SessionDetails sessionDetails) { - PerfSession perfSession = PerfSession.createWithId(sessionDetails.getSessionId()); - SessionManager.getInstance().updatePerfSession(perfSession); - } - - @Override - public boolean isDataCollectionEnabled() { - // If there is no cached config data available for data collection, be conservative. - // Return false. - if (!configResolver.isCollectionEnabledConfigValueAvailable()) { - return false; - } - return ConfigResolver.getInstance().isPerformanceMonitoringEnabled(); - } - - @NonNull - @Override - public Name getSessionSubscriberName() { - return SessionSubscriber.Name.PERFORMANCE; - } - }); + // TODO: Bring back Firebase Sessions dependency to watch for updates to sessions. // In the case of cold start, we create a session and start collecting gauges as early as // possible. 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 1ab9c988376..c01f035af1f 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,9 +30,6 @@ 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.FirebaseSessions; -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; @@ -50,10 +47,6 @@ public class FirebasePerfRegistrar implements ComponentRegistrar { private static final String LIBRARY_NAME = "fire-perf"; private static final String EARLY_LIBRARY_NAME = "fire-perf-early"; - static { - FirebaseSessionsDependencies.INSTANCE.addDependency(SessionSubscriber.Name.PERFORMANCE); - } - @Override @Keep public List> getComponents() { @@ -71,7 +64,6 @@ public List> getComponents() { Component.builder(FirebasePerfEarly.class) .name(EARLY_LIBRARY_NAME) .add(Dependency.required(FirebaseApp.class)) - .add(Dependency.required(FirebaseSessions.class)) .add(Dependency.optionalProvider(StartupTime.class)) .add(Dependency.required(uiExecutor)) .eagerInDefaultApp() @@ -79,7 +71,6 @@ public List> getComponents() { container -> new FirebasePerfEarly( container.get(FirebaseApp.class), - container.get(FirebaseSessions.class), container.getProvider(StartupTime.class).get(), container.get(uiExecutor))) .build(), 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 73c505a8b47..c7eb4ca53f8 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 @@ -19,6 +19,7 @@ import androidx.annotation.Keep; import com.google.android.gms.common.util.VisibleForTesting; import com.google.firebase.perf.application.AppStateMonitor; +import com.google.firebase.perf.application.AppStateUpdateHandler; import com.google.firebase.perf.session.gauges.GaugeManager; import com.google.firebase.perf.v1.ApplicationProcessState; import com.google.firebase.perf.v1.GaugeMetadata; @@ -27,13 +28,14 @@ import java.util.HashSet; import java.util.Iterator; 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 { +public class SessionManager extends AppStateUpdateHandler { @SuppressLint("StaticFieldLeak") private static final SessionManager instance = new SessionManager(); @@ -56,8 +58,11 @@ public final PerfSession perfSession() { } private SessionManager() { - // Start with an empty session ID as the firebase sessions will override with real Id. - this(GaugeManager.getInstance(), PerfSession.createWithId(""), AppStateMonitor.getInstance()); + // Generate a new sessionID for every cold start. + this( + GaugeManager.getInstance(), + PerfSession.createWithId(UUID.randomUUID().toString()), + AppStateMonitor.getInstance()); } @VisibleForTesting @@ -66,6 +71,7 @@ public SessionManager( this.gaugeManager = gaugeManager; this.perfSession = perfSession; this.appStateMonitor = appStateMonitor; + registerForAppState(); } /** @@ -90,6 +96,34 @@ public void setApplicationContext(final Context appContext) { }); } + @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); + } + } + } + /** * Checks if the current {@link PerfSession} is expired/timed out. If so, stop collecting gauges. * diff --git a/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerfRegistrarTest.java b/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerfRegistrarTest.java index 524949cd124..7df39fe6a1e 100644 --- a/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerfRegistrarTest.java +++ b/firebase-perf/src/test/java/com/google/firebase/perf/FirebasePerfRegistrarTest.java @@ -25,7 +25,6 @@ import com.google.firebase.components.Qualified; import com.google.firebase.installations.FirebaseInstallationsApi; import com.google.firebase.remoteconfig.RemoteConfigComponent; -import com.google.firebase.sessions.FirebaseSessions; import java.util.List; import java.util.concurrent.Executor; import org.junit.Test; @@ -60,7 +59,6 @@ public void testGetComponents() { .containsExactly( Dependency.required(Qualified.qualified(UiThread.class, Executor.class)), Dependency.required(FirebaseApp.class), - Dependency.required(FirebaseSessions.class), Dependency.optionalProvider(StartupTime.class)); assertThat(firebasePerfEarlyComponent.isLazy()).isFalse(); 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 f55b89dd001..f3e3795f3f8 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 @@ -16,7 +16,9 @@ import static com.google.common.truth.Truth.assertThat; 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; @@ -31,12 +33,14 @@ import com.google.firebase.perf.session.gauges.GaugeManager; import com.google.firebase.perf.util.Clock; import com.google.firebase.perf.util.Timer; +import com.google.firebase.perf.v1.ApplicationProcessState; import java.lang.ref.WeakReference; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; 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; @@ -83,6 +87,124 @@ public void setApplicationContext_logGaugeMetadata_afterGaugeMetadataManagerIsIn 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 // NotLogGaugeData on new perf session when not Verbose // Mark Session as expired after time limit. diff --git a/firebase-sessions/api.txt b/firebase-sessions/api.txt index 3ae1e4b3fde..20376d6c21a 100644 --- a/firebase-sessions/api.txt +++ b/firebase-sessions/api.txt @@ -1,18 +1,35 @@ // Signature format: 2.0 -package com.google.firebase.sessions { +package com.google.firebase.sessions.api { - public final class FirebaseSessions { - method @NonNull public static com.google.firebase.sessions.FirebaseSessions getInstance(); - method @NonNull public static com.google.firebase.sessions.FirebaseSessions getInstance(@NonNull com.google.firebase.FirebaseApp app); - method public void register(@NonNull com.google.firebase.sessions.api.SessionSubscriber subscriber); - property @NonNull public static final com.google.firebase.sessions.FirebaseSessions instance; - field @NonNull public static final com.google.firebase.sessions.FirebaseSessions.Companion Companion; + public final class FirebaseSessionsDependencies { + method public void addDependency(@NonNull com.google.firebase.sessions.api.SessionSubscriber.Name subscriberName); + method public static void register(@NonNull com.google.firebase.sessions.api.SessionSubscriber subscriber); + field @NonNull public static final com.google.firebase.sessions.api.FirebaseSessionsDependencies INSTANCE; } - public static final class FirebaseSessions.Companion { - method @NonNull public com.google.firebase.sessions.FirebaseSessions getInstance(); - method @NonNull public com.google.firebase.sessions.FirebaseSessions getInstance(@NonNull com.google.firebase.FirebaseApp app); - property @NonNull public final com.google.firebase.sessions.FirebaseSessions instance; + public interface SessionSubscriber { + method @NonNull public com.google.firebase.sessions.api.SessionSubscriber.Name getSessionSubscriberName(); + method public boolean isDataCollectionEnabled(); + method public void onSessionChanged(@NonNull com.google.firebase.sessions.api.SessionSubscriber.SessionDetails sessionDetails); + property public abstract boolean isDataCollectionEnabled; + property @NonNull public abstract com.google.firebase.sessions.api.SessionSubscriber.Name sessionSubscriberName; + } + + public enum SessionSubscriber.Name { + method @NonNull public static com.google.firebase.sessions.api.SessionSubscriber.Name valueOf(@NonNull String name) throws java.lang.IllegalArgumentException; + method @NonNull public static com.google.firebase.sessions.api.SessionSubscriber.Name[] values(); + enum_constant public static final com.google.firebase.sessions.api.SessionSubscriber.Name CRASHLYTICS; + enum_constant @Discouraged(message="This is for testing purposes only.") public static final com.google.firebase.sessions.api.SessionSubscriber.Name MATT_SAYS_HI; + enum_constant public static final com.google.firebase.sessions.api.SessionSubscriber.Name PERFORMANCE; + } + + public static final class SessionSubscriber.SessionDetails { + ctor public SessionSubscriber.SessionDetails(@NonNull String sessionId); + method @NonNull public String component1(); + method @NonNull public com.google.firebase.sessions.api.SessionSubscriber.SessionDetails copy(@NonNull String sessionId); + method @NonNull public String getSessionId(); + property @NonNull public final String sessionId; } } + diff --git a/firebase-sessions/firebase-sessions.gradle.kts b/firebase-sessions/firebase-sessions.gradle.kts index 93fe5dec9e0..d4094560a20 100644 --- a/firebase-sessions/firebase-sessions.gradle.kts +++ b/firebase-sessions/firebase-sessions.gradle.kts @@ -48,8 +48,8 @@ android { } dependencies { - api("com.google.firebase:firebase-common:20.4.2") - api("com.google.firebase:firebase-common-ktx:20.4.2") + api("com.google.firebase:firebase-common:20.4.3") + api("com.google.firebase:firebase-common-ktx:20.4.3") implementation("com.google.firebase:firebase-components:17.1.3") implementation("com.google.firebase:firebase-installations-interop:17.1.1") { 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 a37687fc703..1cf67e0c5e1 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 @@ -23,6 +23,7 @@ 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.Test @@ -57,6 +58,13 @@ class FirebaseSessionsTests { assertThat(FirebaseSessions.instance).isNotNull() } + @Test + fun firebaseSessionsDependenciesDoInitialize() { + assertThat(SessionFirelogPublisher.instance).isNotNull() + assertThat(SessionGenerator.instance).isNotNull() + assertThat(SessionsSettings.instance).isNotNull() + } + companion object { private const val APP_ID = "1:1:android:1a" private const val API_KEY = "API-KEY-API-KEY-API-KEY-API-KEY-API-KEY" diff --git a/firebase-sessions/src/main/AndroidManifest.xml b/firebase-sessions/src/main/AndroidManifest.xml index 662efdb1d7c..55e05b0ba80 100644 --- a/firebase-sessions/src/main/AndroidManifest.xml +++ b/firebase-sessions/src/main/AndroidManifest.xml @@ -18,6 +18,10 @@ + , + private val settings: SessionsSettings, + backgroundDispatcher: CoroutineContext, ) { - private val applicationInfo = SessionEvents.getApplicationInfo(firebaseApp) - private val sessionSettings = - SessionsSettings( - firebaseApp.applicationContext, - blockingDispatcher, - backgroundDispatcher, - firebaseInstallations, - applicationInfo, - ) - private val timeProvider: TimeProvider = Time() - private val sessionGenerator: SessionGenerator - private val eventGDTLogger = EventGDTLogger(transportFactoryProvider) - private val sessionCoordinator = SessionCoordinator(firebaseInstallations, eventGDTLogger) init { - sessionGenerator = SessionGenerator(collectEvents = shouldCollectEvents(), timeProvider) - - val sessionInitiateListener = - object : SessionInitiateListener { - // Avoid making a public function in FirebaseSessions for onInitiateSession. - override suspend fun onInitiateSession(sessionDetails: SessionDetails) { - initiateSessionStart(sessionDetails) - } - } - - val sessionInitiator = - SessionInitiator( - timeProvider, - backgroundDispatcher, - sessionInitiateListener, - sessionSettings, - sessionGenerator, - ) - + Log.d(TAG, "Initializing Firebase Sessions SDK.") val appContext = firebaseApp.applicationContext.applicationContext if (appContext is Application) { - appContext.registerActivityLifecycleCallbacks(sessionInitiator.activityLifecycleCallbacks) - - firebaseApp.addLifecycleEventListener { _, _ -> - Log.w(TAG, "FirebaseApp instance deleted. Sessions library will not collect session data.") - appContext.unregisterActivityLifecycleCallbacks(sessionInitiator.activityLifecycleCallbacks) + appContext.registerActivityLifecycleCallbacks(SessionsActivityLifecycleCallbacks) + + CoroutineScope(backgroundDispatcher).launch { + settings.updateSettings() + if (!settings.sessionsEnabled) { + Log.d(TAG, "Sessions SDK disabled. Not listening to lifecycle events.") + } else { + val lifecycleClient = SessionLifecycleClient(backgroundDispatcher) + lifecycleClient.bindToService() + SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient + + firebaseApp.addLifecycleEventListener { _, _ -> + Log.w(TAG, "FirebaseApp instance deleted. Sessions library will stop collecting data.") + SessionsActivityLifecycleCallbacks.lifecycleClient = null + } + } } } else { Log.e( @@ -88,90 +62,10 @@ internal constructor( } } - /** Register the [subscriber]. This must be called for every dependency. */ - fun register(subscriber: SessionSubscriber) { - FirebaseSessionsDependencies.register(subscriber) - - Log.d( - TAG, - "Registering Sessions SDK subscriber with name: ${subscriber.sessionSubscriberName}, " + - "data collection enabled: ${subscriber.isDataCollectionEnabled}" - ) - - // Immediately call the callback if Sessions generated a session before the - // subscriber subscribed, otherwise subscribers might miss the first session. - if (sessionGenerator.hasGenerateSession) { - subscriber.onSessionChanged( - SessionSubscriber.SessionDetails(sessionGenerator.currentSession.sessionId) - ) - } - } - - private suspend fun initiateSessionStart(sessionDetails: SessionDetails) { - 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." - ) - return - } - - subscribers.values.forEach { subscriber -> - // Notify subscribers, regardless of sampling and data collection state. - subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionDetails.sessionId)) - } - - if (subscribers.values.none { it.isDataCollectionEnabled }) { - Log.d(TAG, "Data Collection is disabled for all subscribers. Skipping this Session Event") - return - } - - Log.d(TAG, "Data Collection is enabled for at least one Subscriber") - - // 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.") - return - } - - if (!sessionGenerator.collectEvents) { - Log.d(TAG, "Sessions SDK has dropped this session due to sampling.") - return - } - - try { - val sessionEvent = - SessionEvents.startSession(firebaseApp, sessionDetails, sessionSettings, subscribers) - sessionCoordinator.attemptLoggingSessionEvent(sessionEvent) - } catch (ex: IllegalStateException) { - // This can happen if the app suddenly deletes the instance of FirebaseApp. - Log.w( - TAG, - "FirebaseApp is not initialized. Sessions library will not collect session data.", - ex - ) - } - } - - /** Calculate whether we should sample events using [sessionSettings] data. */ - private fun shouldCollectEvents(): Boolean { - // Sampling rate of 1 means the SDK will send every event. - val randomValue = Math.random() - return randomValue <= sessionSettings.samplingRate - } - companion object { private const val TAG = "FirebaseSessions" - @JvmStatic val instance: FirebaseSessions - get() = getInstance(Firebase.app) - - @JvmStatic - fun getInstance(app: FirebaseApp): FirebaseSessions = app.get(FirebaseSessions::class.java) + get() = Firebase.app.get(FirebaseSessions::class.java) } } 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 2989dee83a3..ed06c17a4bd 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 @@ -26,10 +26,11 @@ 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.settings.SessionsSettings import kotlinx.coroutines.CoroutineDispatcher /** - * [ComponentRegistrar] for setting up [FirebaseSessions]. + * [ComponentRegistrar] for setting up [FirebaseSessions] and its internal dependencies. * * @hide */ @@ -40,24 +41,74 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { Component.builder(FirebaseSessions::class.java) .name(LIBRARY_NAME) .add(Dependency.required(firebaseApp)) - .add(Dependency.required(firebaseInstallationsApi)) + .add(Dependency.required(sessionsSettings)) .add(Dependency.required(backgroundDispatcher)) - .add(Dependency.required(blockingDispatcher)) - .add(Dependency.requiredProvider(transportFactory)) .factory { container -> FirebaseSessions( + container.get(firebaseApp), + container.get(sessionsSettings), + container.get(backgroundDispatcher), + ) + } + .build(), + Component.builder(SessionGenerator::class.java) + .name("session-generator") + .factory { SessionGenerator(timeProvider = WallClock) } + .build(), + Component.builder(SessionFirelogPublisher::class.java) + .name("session-publisher") + .add(Dependency.required(firebaseApp)) + .add(Dependency.required(firebaseInstallationsApi)) + .add(Dependency.required(sessionsSettings)) + .add(Dependency.requiredProvider(transportFactory)) + .add(Dependency.required(backgroundDispatcher)) + .factory { container -> + SessionFirelogPublisherImpl( container.get(firebaseApp), container.get(firebaseInstallationsApi), + container.get(sessionsSettings), + EventGDTLogger(container.getProvider(transportFactory)), container.get(backgroundDispatcher), + ) + } + .build(), + Component.builder(SessionsSettings::class.java) + .name("sessions-settings") + .add(Dependency.required(firebaseApp)) + .add(Dependency.required(blockingDispatcher)) + .add(Dependency.required(backgroundDispatcher)) + .add(Dependency.required(firebaseInstallationsApi)) + .factory { container -> + SessionsSettings( + container.get(firebaseApp), container.get(blockingDispatcher), - container.getProvider(transportFactory), + container.get(backgroundDispatcher), + container.get(firebaseInstallationsApi), ) } .build(), - LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME) + Component.builder(SessionDatastore::class.java) + .name("sessions-datastore") + .add(Dependency.required(firebaseApp)) + .add(Dependency.required(backgroundDispatcher)) + .factory { container -> + SessionDatastoreImpl( + container.get(firebaseApp).getApplicationContext(), + container.get(backgroundDispatcher) + ) + } + .build(), + Component.builder(SessionLifecycleServiceBinder::class.java) + .name("sessions-service-binder") + .add(Dependency.required(firebaseApp)) + .factory { container -> + SessionLifecycleServiceBinderImpl(container.get(firebaseApp)) + } + .build(), + LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), ) - companion object { + private companion object { private const val LIBRARY_NAME = "fire-sessions" private val firebaseApp = unqualified(FirebaseApp::class.java) @@ -67,5 +118,8 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { private val blockingDispatcher = qualified(Blocking::class.java, CoroutineDispatcher::class.java) private val transportFactory = unqualified(TransportFactory::class.java) + private val sessionFirelogPublisher = unqualified(SessionFirelogPublisher::class.java) + private val sessionGenerator = unqualified(SessionGenerator::class.java) + private val sessionsSettings = unqualified(SessionsSettings::class.java) } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionCoordinator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionCoordinator.kt deleted file mode 100644 index 3cf9f13a3ff..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionCoordinator.kt +++ /dev/null @@ -1,55 +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 com.google.firebase.installations.FirebaseInstallationsApi -import kotlinx.coroutines.tasks.await - -/** - * [SessionCoordinator] is responsible for coordinating the systems in this SDK involved with - * sending a [SessionEvent]. - * - * @hide - */ -internal class SessionCoordinator( - private val firebaseInstallations: FirebaseInstallationsApi, - private val eventGDTLogger: EventGDTLoggerInterface, -) { - suspend fun attemptLoggingSessionEvent(sessionEvent: SessionEvent) { - sessionEvent.sessionData.firebaseInstallationId = - try { - firebaseInstallations.id.await() - } catch (ex: Exception) { - Log.e(TAG, "Error getting Firebase Installation ID: ${ex}. Using an empty ID") - // Use an empty fid if there is any failure. - "" - } - - try { - eventGDTLogger.log(sessionEvent) - - Log.i(TAG, "Successfully logged Session Start event: ${sessionEvent.sessionData.sessionId}") - } catch (ex: RuntimeException) { - Log.e(TAG, "Error logging Session Start event to DataTransport: ", ex) - } - } - - companion object { - private const val TAG = "SessionCoordinator" - } -} 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 new file mode 100644 index 00000000000..86b7aeae58a --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt @@ -0,0 +1,103 @@ +/* + * 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.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 androidx.datastore.preferences.preferencesDataStore +import com.google.firebase.Firebase +import com.google.firebase.app +import java.util.concurrent.atomic.AtomicReference +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): Unit + + /** + * 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.get(SessionDatastore::class.java) + } +} + +internal class SessionDatastoreImpl( + private val context: Context, + private val backgroundDispatcher: CoroutineContext, +) : 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 = + context.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 { + context.dataStore.edit { preferences -> + preferences[FirebaseSessionDataKeys.SESSION_ID] = sessionId + } + } + } + + override fun getCurrentSessionId() = currentSessionFromDatastore.get()?.sessionId + + private fun mapSessionsData(preferences: Preferences): FirebaseSessionsData = + FirebaseSessionsData( + preferences[FirebaseSessionDataKeys.SESSION_ID], + ) + + private companion object { + private val TAG = "FirebaseSessionsRepo" + private val Context.dataStore: DataStore by + preferencesDataStore(name = "firebase_session_data") + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvent.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvent.kt index 1eff07db7fa..cdd1f724706 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvent.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvent.kt @@ -18,6 +18,7 @@ package com.google.firebase.sessions import com.google.firebase.encoders.annotations.Encodable import com.google.firebase.encoders.json.NumberedEnum +import com.google.firebase.processinfo.ProcessDetails /** * Contains the relevant information around a Firebase Session Event. @@ -35,6 +36,12 @@ internal data class SessionEvent( /** Information about the application that is generating the session events. */ val applicationInfo: ApplicationInfo, + + /** Details about this process **/ + val currentProcessDetails: ProcessDetails, + + /** Details about all processes for this app **/ + val appProcessDetails: List ) /** Enum denoting all possible session event types. */ @@ -60,14 +67,14 @@ internal data class SessionInfo( /** What order this Session came in this run of the app. For the first Session this will be 0. */ val sessionIndex: Int, - /** Tracks when the event was initiated */ - var eventTimestampUs: Long, + /** Tracks when the event was initiated. */ + val eventTimestampUs: Long, /** Data collection status of the dependent product SDKs. */ - var dataCollectionStatus: DataCollectionStatus = DataCollectionStatus(), + val dataCollectionStatus: DataCollectionStatus = DataCollectionStatus(), /** Identifies a unique device+app installation: go/firebase-installations */ - var firebaseInstallationId: String = "", + val firebaseInstallationId: String = "", ) /** Contains the data collection state for all dependent SDKs and sampling info */ 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 1769c3ab978..7b385279610 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 @@ -20,6 +20,7 @@ import android.os.Build import com.google.firebase.FirebaseApp import com.google.firebase.encoders.DataEncoder import com.google.firebase.encoders.json.JsonDataEncoderBuilder +import com.google.firebase.processinfo.ProcessDetails import com.google.firebase.sessions.api.SessionSubscriber import com.google.firebase.sessions.settings.SessionsSettings @@ -32,16 +33,15 @@ internal object SessionEvents { .ignoreNullValues(true) .build() - /** - * Construct a Session Start event. - * - * Some mutable fields, e.g. firebaseInstallationId, get populated later. - */ - fun startSession( + /** Construct a Session Start event. */ + fun buildSession( firebaseApp: FirebaseApp, sessionDetails: SessionDetails, sessionsSettings: SessionsSettings, + processDetails: ProcessDetails, + appProcessDetails: List, subscribers: Map = emptyMap(), + firebaseInstallationId: String = "", ) = SessionEvent( eventType = EventType.SESSION_START, @@ -56,8 +56,11 @@ internal object SessionEvents { crashlytics = toDataCollectionState(subscribers[SessionSubscriber.Name.CRASHLYTICS]), sessionSamplingRate = sessionsSettings.samplingRate, ), + firebaseInstallationId, ), - applicationInfo = getApplicationInfo(firebaseApp) + applicationInfo = getApplicationInfo(firebaseApp), + currentProcessDetails = processDetails, + appProcessDetails = appProcessDetails, ) fun getApplicationInfo(firebaseApp: FirebaseApp): ApplicationInfo { 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 new file mode 100644 index 00000000000..c2449f95e00 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt @@ -0,0 +1,132 @@ +/* + * 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 com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.app +import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.processinfo.ProcessDetailsProvider +import com.google.firebase.sessions.api.FirebaseSessionsDependencies +import com.google.firebase.sessions.settings.SessionsSettings +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** Responsible for uploading session events to Firelog. */ +internal interface SessionFirelogPublisher { + + /** Asynchronously logs the session represented by the given [SessionDetails] to Firelog. */ + fun logSession(sessionDetails: SessionDetails): Unit + + companion object { + val instance: SessionFirelogPublisher + get() = Firebase.app.get(SessionFirelogPublisher::class.java) + } +} + +/** + * [SessionFirelogPublisher] is responsible for publishing sessions to firelog + * + * @hide + */ +internal class SessionFirelogPublisherImpl( + private val firebaseApp: FirebaseApp, + private val firebaseInstallations: FirebaseInstallationsApi, + private val sessionSettings: SessionsSettings, + private val eventGDTLogger: EventGDTLoggerInterface, + private val backgroundDispatcher: CoroutineContext, +) : SessionFirelogPublisher { + + /** + * Logs the session represented by the given [SessionDetails] to Firelog on a background thread. + * + * 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) { + CoroutineScope(backgroundDispatcher).launch { + if (shouldLogSession()) { + attemptLoggingSessionEvent( + SessionEvents.buildSession( + firebaseApp, + sessionDetails, + sessionSettings, + ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext), + ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext), + FirebaseSessionsDependencies.getRegisteredSubscribers(), + getFirebaseInstallationId(), + ) + ) + } + } + } + + /** Attempts to write the given [SessionEvent] to firelog. Failures are logged and ignored. */ + private fun attemptLoggingSessionEvent(sessionEvent: SessionEvent) { + try { + eventGDTLogger.log(sessionEvent) + Log.d(TAG, "Successfully logged Session Start event: ${sessionEvent.sessionData.sessionId}") + } catch (ex: RuntimeException) { + Log.e(TAG, "Error logging Session Start event to DataTransport: ", ex) + } + } + + /** 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") + + // 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.") + return false + } + + if (!shouldCollectEvents()) { + Log.d(TAG, "Sessions SDK has dropped this session due to sampling.") + return false + } + + return true + } + + /** Gets the Firebase Installation ID for the current app installation. */ + private suspend fun getFirebaseInstallationId() = + try { + firebaseInstallations.id.await() + } catch (ex: Exception) { + Log.e(TAG, "Error getting Firebase Installation ID. Using an empty ID", ex) + // Use an empty fid if there is any failure. + "" + } + + /** Calculate whether we should sample events using [SessionsSettings] data. */ + private fun shouldCollectEvents(): Boolean { + // Sampling rate of 1 means the SDK will send every event. + return randomValueForSampling <= sessionSettings.samplingRate + } + + 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 b526dc0558d..a266362233a 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,6 +16,8 @@ package com.google.firebase.sessions +import com.google.firebase.Firebase +import com.google.firebase.app import java.util.UUID /** @@ -33,7 +35,6 @@ internal data class SessionDetails( * [SessionDetails] up to date with the latest values. */ internal class SessionGenerator( - val collectEvents: Boolean, private val timeProvider: TimeProvider, private val uuidGenerator: () -> UUID = UUID::randomUUID ) { @@ -62,4 +63,9 @@ internal class SessionGenerator( } private fun generateSessionId() = uuidGenerator().toString().replace("-", "").lowercase() + + internal companion object { + val instance: SessionGenerator + get() = Firebase.app.get(SessionGenerator::class.java) + } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionInitiator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionInitiator.kt deleted file mode 100644 index 84fbd99c72d..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionInitiator.kt +++ /dev/null @@ -1,83 +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.app.Application.ActivityLifecycleCallbacks -import android.os.Bundle -import com.google.firebase.sessions.settings.SessionsSettings -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * The [SessionInitiator] is responsible for calling [SessionInitiateListener.onInitiateSession] - * with a generated [SessionDetails] on the [backgroundDispatcher] whenever a new session initiates. - * This will happen at a cold start of the app, and when the app has been in the background for a - * period of time (default 30 min) and then comes back to the foreground. - */ -internal class SessionInitiator( - private val timeProvider: TimeProvider, - private val backgroundDispatcher: CoroutineContext, - private val sessionInitiateListener: SessionInitiateListener, - private val sessionsSettings: SessionsSettings, - private val sessionGenerator: SessionGenerator, -) { - private var backgroundTime = timeProvider.elapsedRealtime() - - init { - initiateSession() - } - - fun appBackgrounded() { - backgroundTime = timeProvider.elapsedRealtime() - } - - fun appForegrounded() { - val interval = timeProvider.elapsedRealtime() - backgroundTime - val sessionTimeout = sessionsSettings.sessionRestartTimeout - if (interval > sessionTimeout) { - initiateSession() - } - } - - private fun initiateSession() { - // Generate the session details on main thread so the timestamp is as current as possible. - val sessionDetails = sessionGenerator.generateNewSession() - - CoroutineScope(backgroundDispatcher).launch { - sessionInitiateListener.onInitiateSession(sessionDetails) - } - } - - internal val activityLifecycleCallbacks = - object : ActivityLifecycleCallbacks { - override fun onActivityResumed(activity: Activity) = appForegrounded() - - override fun onActivityPaused(activity: Activity) = appBackgrounded() - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit - - override fun onActivityStarted(activity: Activity) = Unit - - override fun onActivityStopped(activity: Activity) = Unit - - override fun onActivityDestroyed(activity: Activity) = Unit - - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - } -} 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 new file mode 100644 index 00000000000..e9e42f05837 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt @@ -0,0 +1,215 @@ +/* + * 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.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: $sessionId") + + 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.instance.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. + */ + 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 new file mode 100644 index 00000000000..a1cb70be8ee --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt @@ -0,0 +1,244 @@ +/* + * 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) + } + } + lastMsgTimeMs = msg.getWhen() + } + + /** + * Handles a foregrounding event by any activity owned by the aplication 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() + } + } + + /** + * 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()}") + } + + /** + * 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() { + SessionGenerator.instance.generateNewSession() + Log.d(TAG, "Generated new session ${SessionGenerator.instance.currentSession.sessionId}") + broadcastSession() + SessionDatastore.instance.updateSessionId(SessionGenerator.instance.currentSession.sessionId) + } + + /** + * 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: ${SessionGenerator.instance.currentSession}") + SessionFirelogPublisher.instance.logSession(SessionGenerator.instance.currentSession) + boundClients.forEach { maybeSendSessionToClient(it) } + } + + private fun maybeSendSessionToClient(client: Messenger) { + 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") + storedSession?.let { sendSessionToClient(client, it) } + } + } + + /** 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.getLooper()) + messenger = Messenger(messageHandler) + } + + /** Called when a new [SessionLifecycleClient] binds to this service. */ + override fun onBind(intent: Intent): IBinder? { + Log.d(TAG, "Service bound to new client") + val callbackMessenger = getClientCallback(intent) + if (callbackMessenger != null) { + val clientBoundMsg = Message.obtain(null, CLIENT_BOUND, 0, 0) + clientBoundMsg.replyTo = callbackMessenger + messageHandler?.sendMessage(clientBoundMsg) + } + return 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 new file mode 100644 index 00000000000..b8d4f9e4f27 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt @@ -0,0 +1,63 @@ +/* + * 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 com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.app + +/** Interface for binding with the [SessionLifecycleService]. */ +internal 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): Unit + + companion object { + val instance: SessionLifecycleServiceBinder + get() = Firebase.app.get(SessionLifecycleServiceBinder::class.java) + } +} + +internal class SessionLifecycleServiceBinderImpl(private val firebaseApp: FirebaseApp) : + SessionLifecycleServiceBinder { + + override fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) { + val appContext = firebaseApp.applicationContext.applicationContext + 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) + appContext.bindService( + intent, + serviceConnection, + Context.BIND_IMPORTANT or Context.BIND_AUTO_CREATE + ) + } + } + + companion object { + const val TAG = "SessionLifecycleServiceBinder" + } +} 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 new file mode 100644 index 00000000000..b72c1da5cf3 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt @@ -0,0 +1,60 @@ +/* + * 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.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import androidx.annotation.VisibleForTesting + +/** + * Lifecycle callbacks that will inform the [SessionLifecycleClient] 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() + } + } + } + + override fun onActivityResumed(activity: Activity) { + lifecycleClient?.foregrounded() ?: run { hasPendingForeground = true } + } + + override fun onActivityPaused(activity: Activity) { + lifecycleClient?.backgrounded() + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/Time.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt similarity index 92% rename from firebase-sessions/src/main/kotlin/com/google/firebase/sessions/Time.kt rename to firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt index d7216b40937..706285de337 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/Time.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt @@ -27,7 +27,7 @@ internal interface TimeProvider { } /** "Wall clock" time provider. */ -internal class Time : TimeProvider { +internal object WallClock : TimeProvider { /** * Gets the [Duration] elapsed in "wall clock" time since device boot. * @@ -45,8 +45,6 @@ internal class Time : TimeProvider { */ override fun currentTimeUs(): Long = System.currentTimeMillis() * US_PER_MILLIS - companion object { - /** Microseconds per millisecond. */ - private const val US_PER_MILLIS = 1000L - } + /** Microseconds per millisecond. */ + private const val US_PER_MILLIS = 1000L } 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 4fa56af50a9..29d67b346f1 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 @@ -46,13 +46,15 @@ object FirebaseSessionsDependencies { // The dependency is locked until the subscriber registers itself. dependencies[subscriberName] = Dependency(Mutex(locked = true)) + Log.d(TAG, "Dependency to $subscriberName added.") } /** * Register and unlock the subscriber. This must be called before [getRegisteredSubscribers] can * return. */ - internal fun register(subscriber: SessionSubscriber) { + @JvmStatic + fun register(subscriber: SessionSubscriber) { val subscriberName = subscriber.sessionSubscriberName val dependency = getDependency(subscriberName) @@ -61,6 +63,7 @@ object FirebaseSessionsDependencies { return } dependency.subscriber = subscriber + Log.d(TAG, "Subscriber $subscriberName registered.") // Unlock to show the subscriber has been registered, it is possible to get it now. dependency.mutex.unlock() 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 6ac5c2057cd..d067bb04083 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 @@ -68,6 +68,7 @@ internal class RemoteSettings( fetchInProgress.withLock { // Double check if cache is expired. If not, return. if (!settingsCache.hasCacheExpired()) { + Log.d(TAG, "Remote settings cache not expired. Using cached values.") return } @@ -89,9 +90,11 @@ internal class RemoteSettings( "X-Crashlytics-API-Client-Version" to appInfo.sessionSdkVersion ) + Log.d(TAG, "Fetching settings from server.") configsFetcher.doConfigFetch( headerOptions = options, onSuccess = { + Log.d(TAG, "Fetched settings: $it") var sessionsEnabled: Boolean? = null var sessionSamplingRate: Double? = null var sessionTimeoutSeconds: Int? = null diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt index 36fa222b77b..c51918e1d9c 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionsSettings.kt @@ -20,8 +20,12 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.app import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.sessions.ApplicationInfo +import com.google.firebase.sessions.SessionEvents import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -31,7 +35,7 @@ internal class SessionsSettings( private val localOverrideSettings: SettingsProvider, private val remoteSettings: SettingsProvider, ) { - constructor( + private constructor( context: Context, blockingDispatcher: CoroutineContext, backgroundDispatcher: CoroutineContext, @@ -53,6 +57,19 @@ internal class SessionsSettings( ), ) + constructor( + firebaseApp: FirebaseApp, + blockingDispatcher: CoroutineContext, + backgroundDispatcher: CoroutineContext, + firebaseInstallationsApi: FirebaseInstallationsApi + ) : this( + firebaseApp.applicationContext, + blockingDispatcher, + backgroundDispatcher, + firebaseInstallationsApi, + SessionEvents.getApplicationInfo(firebaseApp), + ) + // Order of preference for all the configs below: // 1. Honor local overrides // 2. If no local overrides, use remote config @@ -117,8 +134,11 @@ internal class SessionsSettings( remoteSettings.updateSettings() } - private companion object { - const val SESSION_CONFIGS_NAME = "firebase_session_settings" + internal companion object { + private const val SESSION_CONFIGS_NAME = "firebase_session_settings" + + val instance: SessionsSettings + get() = Firebase.app.get(SessionsSettings::class.java) private val Context.dataStore: DataStore by preferencesDataStore(name = SESSION_CONFIGS_NAME) diff --git a/firebase-sessions/src/test/AndroidManifest.xml b/firebase-sessions/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..4eccb7649da --- /dev/null +++ b/firebase-sessions/src/test/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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 3ed1b958b89..b636d53e3dc 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 @@ -42,7 +42,7 @@ class EventGDTLoggerTest { fun event_logsToGoogleDataTransport() = runTest { val fakeFirebaseApp = FakeFirebaseApp() val sessionEvent = - SessionEvents.startSession( + SessionEvents.buildSession( fakeFirebaseApp.firebaseApp, TestSessionEventData.TEST_SESSION_DETAILS, SessionsSettings( 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 e4173cb89a1..e8a1965ed0e 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 @@ -46,7 +46,7 @@ class SessionEventEncoderTest { fun sessionEvent_encodesToJson() = runTest { val fakeFirebaseApp = FakeFirebaseApp() val sessionEvent = - SessionEvents.startSession( + SessionEvents.buildSession( fakeFirebaseApp.firebaseApp, TestSessionEventData.TEST_SESSION_DETAILS, SessionsSettings( 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 4ee92f8acc1..682371491df 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 @@ -41,7 +41,7 @@ class SessionEventTest { fun sessionStart_populatesSessionDetailsCorrectly() = runTest { val fakeFirebaseApp = FakeFirebaseApp() val sessionEvent = - SessionEvents.startSession( + SessionEvents.buildSession( fakeFirebaseApp.firebaseApp, TEST_SESSION_DETAILS, SessionsSettings( @@ -61,7 +61,7 @@ class SessionEventTest { val context = firebaseApp.applicationContext val sessionEvent = - SessionEvents.startSession( + SessionEvents.buildSession( firebaseApp, TEST_SESSION_DETAILS, SessionsSettings( diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionCoordinatorTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt similarity index 63% rename from firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionCoordinatorTest.kt rename to firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt index fbf17a72083..1df0021d992 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionCoordinatorTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt @@ -19,55 +19,67 @@ 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.api.FirebaseSessionsDependencies +import com.google.firebase.sessions.api.SessionSubscriber import com.google.firebase.sessions.settings.SessionsSettings 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.FakeSessionSubscriber import com.google.firebase.sessions.testing.FakeSettingsProvider import com.google.firebase.sessions.testing.TestSessionEventData 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.Before import org.junit.Test import org.junit.runner.RunWith @OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) -class SessionCoordinatorTest { +class SessionFirelogPublisherTest { + @Before + fun setUp() { + val crashlyticsSubscriber = + FakeSessionSubscriber(sessionSubscriberName = SessionSubscriber.Name.CRASHLYTICS) + FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.CRASHLYTICS) + FirebaseSessionsDependencies.register(crashlyticsSubscriber) + } + + @After + fun cleanUp() { + FirebaseApp.clearInstancesForTest() + FirebaseSessionsDependencies.reset() + } + @Test - fun attemptLoggingSessionEvent_populatesFid() = runTest { + fun logSession_populatesFid() = runTest { + val fakeFirebaseApp = FakeFirebaseApp() val fakeEventGDTLogger = FakeEventGDTLogger() val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val sessionCoordinator = - SessionCoordinator( + val sessionsSettings = + SessionsSettings( + localOverrideSettings = FakeSettingsProvider(), + remoteSettings = FakeSettingsProvider(), + ) + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, firebaseInstallations, + sessionsSettings, eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) // Construct an event with no fid set. - val fakeFirebaseApp = FakeFirebaseApp() - val sessionEvent = - SessionEvents.startSession( - fakeFirebaseApp.firebaseApp, - TestSessionEventData.TEST_SESSION_DETAILS, - SessionsSettings( - localOverrideSettings = FakeSettingsProvider(), - remoteSettings = FakeSettingsProvider(), - ), - ) - - sessionCoordinator.attemptLoggingSessionEvent(sessionEvent) + publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() - assertThat(sessionEvent.sessionData.firebaseInstallationId).isEqualTo("FaKeFiD") assertThat(fakeEventGDTLogger.loggedEvent!!.sessionData.firebaseInstallationId) .isEqualTo("FaKeFiD") } - - @After - fun cleanUp() { - FirebaseApp.clearInstancesForTest() - } } 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 59be72ea4b4..7f29fb66ae7 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 @@ -42,7 +42,6 @@ class SessionGeneratorTest { fun currentSession_beforeGenerate_throwsUninitialized() { val sessionGenerator = SessionGenerator( - collectEvents = false, timeProvider = FakeTimeProvider(), ) @@ -53,7 +52,6 @@ class SessionGeneratorTest { fun hasGenerateSession_beforeGenerate_returnsFalse() { val sessionGenerator = SessionGenerator( - collectEvents = false, timeProvider = FakeTimeProvider(), ) @@ -64,7 +62,6 @@ class SessionGeneratorTest { fun hasGenerateSession_afterGenerate_returnsTrue() { val sessionGenerator = SessionGenerator( - collectEvents = false, timeProvider = FakeTimeProvider(), ) @@ -77,7 +74,6 @@ class SessionGeneratorTest { fun generateNewSession_generatesValidSessionIds() { val sessionGenerator = SessionGenerator( - collectEvents = true, timeProvider = FakeTimeProvider(), ) @@ -96,7 +92,6 @@ class SessionGeneratorTest { fun generateNewSession_generatesValidSessionDetails() { val sessionGenerator = SessionGenerator( - collectEvents = true, timeProvider = FakeTimeProvider(), uuidGenerator = UUIDs()::next, ) @@ -123,7 +118,6 @@ class SessionGeneratorTest { fun generateNewSession_incrementsSessionIndex_keepsFirstSessionId() { val sessionGenerator = SessionGenerator( - collectEvents = true, timeProvider = FakeTimeProvider(), uuidGenerator = UUIDs()::next, ) diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionInitiatorTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionInitiatorTest.kt deleted file mode 100644 index 891c18796ec..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionInitiatorTest.kt +++ /dev/null @@ -1,233 +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 com.google.common.truth.Truth.assertThat -import com.google.firebase.FirebaseApp -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.sessions.settings.SessionsSettings -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.runCurrent -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 SessionInitiatorTest { - private class SessionInitiateCounter : SessionInitiateListener { - var count = 0 - private set - - override suspend fun onInitiateSession(sessionDetails: SessionDetails) { - count++ - } - } - - @Test - fun coldStart_initiatesSession() = runTest { - val sessionInitiateCounter = SessionInitiateCounter() - val fakeTimeProvider = FakeTimeProvider() - val settings = - SessionsSettings( - localOverrideSettings = FakeSettingsProvider(), - remoteSettings = FakeSettingsProvider(), - ) - - // Simulate a cold start by simply constructing the SessionInitiator object - SessionInitiator( - fakeTimeProvider, - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - sessionInitiateListener = sessionInitiateCounter, - settings, - SessionGenerator(collectEvents = false, fakeTimeProvider), - ) - - // Run onInitiateSession suspend function. - runCurrent() - - // Session on cold start - assertThat(sessionInitiateCounter.count).isEqualTo(1) - } - - @Test - fun appForegrounded_largeInterval_initiatesSession() = runTest { - val fakeTimeProvider = FakeTimeProvider() - val sessionInitiateCounter = SessionInitiateCounter() - val settings = - SessionsSettings( - localOverrideSettings = FakeSettingsProvider(), - remoteSettings = FakeSettingsProvider(), - ) - - val sessionInitiator = - SessionInitiator( - fakeTimeProvider, - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - sessionInitiateListener = sessionInitiateCounter, - settings, - SessionGenerator(collectEvents = false, fakeTimeProvider), - ) - - // Run onInitiateSession suspend function. - runCurrent() - - // First session on cold start - assertThat(sessionInitiateCounter.count).isEqualTo(1) - - // Enough tome to initiate a new session, and then foreground - fakeTimeProvider.addInterval(LARGE_INTERVAL) - sessionInitiator.appForegrounded() - - runCurrent() - - // Another session initiated - assertThat(sessionInitiateCounter.count).isEqualTo(2) - } - - @Test - fun appForegrounded_smallInterval_doesNotInitiatesSession() = runTest { - val fakeTimeProvider = FakeTimeProvider() - val sessionInitiateCounter = SessionInitiateCounter() - val settings = - SessionsSettings( - localOverrideSettings = FakeSettingsProvider(), - remoteSettings = FakeSettingsProvider(), - ) - - val sessionInitiator = - SessionInitiator( - fakeTimeProvider, - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - sessionInitiateListener = sessionInitiateCounter, - settings, - SessionGenerator(collectEvents = false, fakeTimeProvider), - ) - - // Run onInitiateSession suspend function. - runCurrent() - - // First session on cold start - assertThat(sessionInitiateCounter.count).isEqualTo(1) - - // Not enough time to initiate a new session, and then foreground - fakeTimeProvider.addInterval(SMALL_INTERVAL) - sessionInitiator.appForegrounded() - - runCurrent() - - // No new session - assertThat(sessionInitiateCounter.count).isEqualTo(1) - } - - @Test - fun appForegrounded_background_foreground_largeIntervals_initiatesSessions() = runTest { - val fakeTimeProvider = FakeTimeProvider() - val sessionInitiateCounter = SessionInitiateCounter() - val settings = - SessionsSettings( - localOverrideSettings = FakeSettingsProvider(), - remoteSettings = FakeSettingsProvider(), - ) - - val sessionInitiator = - SessionInitiator( - fakeTimeProvider, - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - sessionInitiateListener = sessionInitiateCounter, - settings, - SessionGenerator(collectEvents = false, fakeTimeProvider), - ) - - // Run onInitiateSession suspend function. - runCurrent() - - assertThat(sessionInitiateCounter.count).isEqualTo(1) - - fakeTimeProvider.addInterval(LARGE_INTERVAL) - sessionInitiator.appForegrounded() - - runCurrent() - - assertThat(sessionInitiateCounter.count).isEqualTo(2) - - sessionInitiator.appBackgrounded() - fakeTimeProvider.addInterval(LARGE_INTERVAL) - sessionInitiator.appForegrounded() - - runCurrent() - - assertThat(sessionInitiateCounter.count).isEqualTo(3) - } - - @Test - fun appForegrounded_background_foreground_smallIntervals_doesNotInitiateNewSessions() = runTest { - val fakeTimeProvider = FakeTimeProvider() - val sessionInitiateCounter = SessionInitiateCounter() - val settings = - SessionsSettings( - localOverrideSettings = FakeSettingsProvider(), - remoteSettings = FakeSettingsProvider(), - ) - - val sessionInitiator = - SessionInitiator( - fakeTimeProvider, - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - sessionInitiateListener = sessionInitiateCounter, - settings, - SessionGenerator(collectEvents = false, fakeTimeProvider), - ) - - // Run onInitiateSession suspend function. - runCurrent() - - // First session on cold start - assertThat(sessionInitiateCounter.count).isEqualTo(1) - - fakeTimeProvider.addInterval(SMALL_INTERVAL) - sessionInitiator.appForegrounded() - - runCurrent() - - assertThat(sessionInitiateCounter.count).isEqualTo(1) - - sessionInitiator.appBackgrounded() - fakeTimeProvider.addInterval(SMALL_INTERVAL) - sessionInitiator.appForegrounded() - - runCurrent() - - assertThat(sessionInitiateCounter.count).isEqualTo(1) - } - - @After - fun cleanUp() { - FirebaseApp.clearInstancesForTest() - } - - companion object { - private val SMALL_INTERVAL = 29.minutes // not enough time to initiate a new session - private val LARGE_INTERVAL = 31.minutes // enough to initiate another session - } -} 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 new file mode 100644 index 00000000000..2ccd7dbb20a --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt @@ -0,0 +1,279 @@ +/* + * 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 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 { + + lateinit var fakeService: FakeSessionLifecycleServiceBinder + + @Before + fun setUp() { + val firebaseApp = + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) + .setApiKey(FakeFirebaseApp.MOCK_API_KEY) + .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) + .build() + ) + fakeService = firebaseApp.get(FakeSessionLifecycleServiceBinder::class.java) + } + + @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() + + 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() + 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() + 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() + + 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() + + 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() + + 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() + + fakeService.serviceConnected() + client.foregrounded() + client.backgrounded() + + waitForMessages() + assertThat(fakeService.receivedMessageCodes).isEmpty() + } + + @Test + fun doesNotSendLifecycleEventsWithoutEnabledSubscribers() = + runTest(UnconfinedTestDispatcher()) { + val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) + val crashlyticsSubscriber = addSubscriber(false, SessionSubscriber.Name.CRASHLYTICS) + val perfSubscriber = addSubscriber(false, SessionSubscriber.Name.PERFORMANCE) + client.bindToService() + + fakeService.serviceConnected() + client.foregrounded() + client.backgrounded() + + waitForMessages() + assertThat(fakeService.receivedMessageCodes).isEmpty() + } + + @Test + fun sendsLifecycleEventsWhenAtLeastOneEnabledSubscriber() = + runTest(UnconfinedTestDispatcher()) { + val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) + val crashlyticsSubscriber = addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) + val perfSubscriber = addSubscriber(false, SessionSubscriber.Name.PERFORMANCE) + client.bindToService() + + 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() + + 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 perfSubscriber = addSubscriber(true, SessionSubscriber.Name.PERFORMANCE) + client.bindToService() + + fakeService.serviceConnected() + fakeService.broadcastSession("123") + + waitForMessages() + assertThat(crashlyticsSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) + assertThat(perfSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) + } + + @Test + fun handleSessionUpdate_sendsToAllSubscribersAsLongAsOneIsEnabled() = + runTest(UnconfinedTestDispatcher()) { + val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) + val crashlyticsSubscriber = addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) + val perfSubscriber = addSubscriber(false, SessionSubscriber.Name.PERFORMANCE) + client.bindToService() + + fakeService.serviceConnected() + fakeService.broadcastSession("123") + + waitForMessages() + assertThat(crashlyticsSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) + assertThat(perfSubscriber.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 new file mode 100644 index 00000000000..682a9ddfbbb --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt @@ -0,0 +1,244 @@ +/* + * 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.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.FakeFirelogPublisher +import com.google.firebase.sessions.testing.FakeSessionDatastore +import java.time.Duration +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@MediumTest +@LooperMode(PAUSED) +@RunWith(RobolectricTestRunner::class) +internal class SessionLifecycleServiceTest { + + lateinit var service: ServiceController + lateinit var firebaseApp: FirebaseApp + + 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() { + val context = ApplicationProvider.getApplicationContext() + firebaseApp = + 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() + firebaseApp.get(FakeSessionDatastore::class.java).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() = + firebaseApp.get(FakeFirelogPublisher::class.java).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 new file mode 100644 index 00000000000..3f9ac95b21c --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt @@ -0,0 +1,129 @@ +/* + * 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 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 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, + ) + ) + + val firebaseApp = + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) + .setApiKey(FakeFirebaseApp.MOCK_API_KEY) + .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) + .build() + ) + fakeService = firebaseApp.get(FakeSessionLifecycleServiceBinder::class.java) + } + + @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() + 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() + 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/testing/FakeFirelogPublisher.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt new file mode 100644 index 00000000000..2975447bbaa --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt @@ -0,0 +1,34 @@ +/* + * 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/FakeSessionDatastore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt new file mode 100644 index 00000000000..f98852032c8 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt @@ -0,0 +1,35 @@ +/* + * 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.SessionDatastore + +/** + * 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 + } + + 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 new file mode 100644 index 00000000000..0d4e58e2014 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt @@ -0,0 +1,88 @@ +/* + * 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/FakeSessionSubscriber.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionSubscriber.kt index 86b8ecbccf6..e95059b8691 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionSubscriber.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionSubscriber.kt @@ -24,5 +24,10 @@ internal class FakeSessionSubscriber( override val isDataCollectionEnabled: Boolean = true, override val sessionSubscriberName: SessionSubscriber.Name = CRASHLYTICS, ) : SessionSubscriber { - override fun onSessionChanged(sessionDetails: SessionSubscriber.SessionDetails) = Unit + + val sessionChangedEvents = mutableListOf() + + override fun onSessionChanged(sessionDetails: SessionSubscriber.SessionDetails) { + sessionChangedEvents.add(sessionDetails) + } } 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 new file mode 100644 index 00000000000..1ae328c5329 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt @@ -0,0 +1,117 @@ +/* + * 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 androidx.annotation.Keep +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.components.Component +import com.google.firebase.components.ComponentRegistrar +import com.google.firebase.components.Dependency +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.BuildConfig +import com.google.firebase.sessions.FirebaseSessions +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.WallClock +import com.google.firebase.sessions.settings.SessionsSettings +import kotlinx.coroutines.CoroutineDispatcher + +/** + * [ComponentRegistrar] for setting up Fake components for [FirebaseSessions] and its internal + * dependencies for unit tests. + * + * @hide + */ +@Keep +internal class FirebaseSessionsFakeRegistrar : ComponentRegistrar { + override fun getComponents() = + listOf( + Component.builder(SessionGenerator::class.java) + .name("session-generator") + .factory { SessionGenerator(timeProvider = WallClock) } + .build(), + Component.builder(FakeFirelogPublisher::class.java) + .name("fake-session-publisher") + .factory { FakeFirelogPublisher() } + .build(), + Component.builder(SessionFirelogPublisher::class.java) + .name("session-publisher") + .add(Dependency.required(fakeFirelogPublisher)) + .factory { container -> container.get(fakeFirelogPublisher) } + .build(), + Component.builder(SessionsSettings::class.java) + .name("sessions-settings") + .add(Dependency.required(firebaseApp)) + .add(Dependency.required(firebaseInstallationsApi)) + .add(Dependency.required(backgroundDispatcher)) + .factory { container -> + SessionsSettings( + container.get(firebaseApp), + container.get(blockingDispatcher), + container.get(backgroundDispatcher), + fakeFirebaseInstallations, + ) + } + .build(), + Component.builder(FakeSessionDatastore::class.java) + .name("fake-sessions-datastore") + .factory { FakeSessionDatastore() } + .build(), + Component.builder(SessionDatastore::class.java) + .name("sessions-datastore") + .add(Dependency.required(fakeDatastore)) + .factory { container -> container.get(fakeDatastore) } + .build(), + Component.builder(FakeSessionLifecycleServiceBinder::class.java) + .name("fake-sessions-service-binder") + .factory { FakeSessionLifecycleServiceBinder() } + .build(), + Component.builder(SessionLifecycleServiceBinder::class.java) + .name("sessions-service-binder") + .add(Dependency.required(fakeServiceBinder)) + .factory { container -> container.get(fakeServiceBinder) } + .build(), + LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), + ) + + private companion object { + private const val LIBRARY_NAME = "fire-sessions" + + private val firebaseApp = unqualified(FirebaseApp::class.java) + private val firebaseInstallationsApi = unqualified(FirebaseInstallationsApi::class.java) + private val backgroundDispatcher = + qualified(Background::class.java, CoroutineDispatcher::class.java) + private val blockingDispatcher = + qualified(Blocking::class.java, CoroutineDispatcher::class.java) + private val transportFactory = unqualified(TransportFactory::class.java) + private val fakeFirelogPublisher = unqualified(FakeFirelogPublisher::class.java) + private val fakeDatastore = unqualified(FakeSessionDatastore::class.java) + private val fakeServiceBinder = unqualified(FakeSessionLifecycleServiceBinder::class.java) + private val sessionGenerator = unqualified(SessionGenerator::class.java) + private val sessionsSettings = unqualified(SessionsSettings::class.java) + + private val fakeFirebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + } +} diff --git a/firebase-sessions/test-app/src/androidTest/kotlin/com/google/firebase/testing/sessions/FirebaseSessionsTest.kt b/firebase-sessions/test-app/src/androidTest/kotlin/com/google/firebase/testing/sessions/FirebaseSessionsTest.kt index 216855bcb9e..192084aad6e 100644 --- a/firebase-sessions/test-app/src/androidTest/kotlin/com/google/firebase/testing/sessions/FirebaseSessionsTest.kt +++ b/firebase-sessions/test-app/src/androidTest/kotlin/com/google/firebase/testing/sessions/FirebaseSessionsTest.kt @@ -16,13 +16,14 @@ package com.google.firebase.testing.sessions +import androidx.lifecycle.Lifecycle.State +import androidx.test.core.app.ActivityScenario 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.ktx.Firebase -import com.google.firebase.ktx.initialize -import com.google.firebase.sessions.FirebaseSessions +import com.google.firebase.initialize import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.api.SessionSubscriber import org.junit.After @@ -44,22 +45,33 @@ class FirebaseSessionsTest { @Test fun initializeSessions_generatesSessionEvent() { - // Force the Firebase Sessions SDK to initialize. - assertThat(FirebaseSessions.instance).isNotNull() - // Add a fake dependency and register it, otherwise sessions will never send. val fakeSessionSubscriber = FakeSessionSubscriber() - FirebaseSessions.instance.register(fakeSessionSubscriber) - - // Wait for the session start event to send. - Thread.sleep(TIME_TO_LOG_SESSION) + FirebaseSessionsDependencies.register(fakeSessionSubscriber) - // Assert that some session was generated and sent to the subscriber. - assertThat(fakeSessionSubscriber.sessionDetails).isNotNull() + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { + // Wait for the settings to be fetched from the server. + Thread.sleep(TIME_TO_READ_SETTINGS) + } + // Move the activity to the background and then foreground + // This is necessary because the initial app launch does not yet know whether the sdk is + // enabled, and so the first session isnt' created until a lifecycle event happens after the + // settings are read. + scenario.moveToState(State.CREATED) + scenario.moveToState(State.RESUMED) + scenario.onActivity { + // Wait for the session start event to send. + Thread.sleep(TIME_TO_LOG_SESSION) + // Assert that some session was generated and sent to the subscriber. + assertThat(fakeSessionSubscriber.sessionDetails).isNotNull() + } + } } companion object { - private const val TIME_TO_LOG_SESSION = 60_000L + private const val TIME_TO_READ_SETTINGS = 60_000L + private const val TIME_TO_LOG_SESSION = 10_000L init { FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.MATT_SAYS_HI) diff --git a/firebase-sessions/test-app/src/main/AndroidManifest.xml b/firebase-sessions/test-app/src/main/AndroidManifest.xml index 2cda3e2bc0d..a07d1b913d7 100644 --- a/firebase-sessions/test-app/src/main/AndroidManifest.xml +++ b/firebase-sessions/test-app/src/main/AndroidManifest.xml @@ -1,23 +1,75 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/BaseActivity.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/BaseActivity.kt new file mode 100644 index 00000000000..30ded36512f --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/BaseActivity.kt @@ -0,0 +1,72 @@ +/* + * 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.testing.sessions + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo +import android.app.Application +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.google.firebase.FirebaseApp + +/** */ +open class BaseActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + FirebaseApp.initializeApp(this) + Log.i(TAG, "onCreate - ${getProcessName()} - ${getImportance()}") + } + + override fun onPause() { + super.onPause() + Log.i(TAG, "onPause - ${getProcessName()} - ${getImportance()}") + } + + override fun onStop() { + super.onStop() + Log.i(TAG, "onStop - ${getProcessName()} - ${getImportance()}") + } + + override fun onResume() { + super.onResume() + Log.i(TAG, "onResume - ${getProcessName()} - ${getImportance()}") + } + + override fun onStart() { + super.onStart() + Log.i(TAG, "onStart - ${getProcessName()} - ${getImportance()}") + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "onDestroy - ${getProcessName()} - ${getImportance()}") + } + + private fun getImportance(): Int { + val processInfo = RunningAppProcessInfo() + ActivityManager.getMyMemoryState(processInfo) + return processInfo.importance + } + + private fun getProcessName(): String = Application.getProcessName() + + companion object { + val TAG = "BaseActivity" + } +} 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 new file mode 100644 index 00000000000..89d2f03f1ce --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt @@ -0,0 +1,49 @@ +/* + * 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.testing.sessions + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.Toast + +class CrashBroadcastReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + Log.i(TAG, "Received intent: $intent") + when (intent.action) { + CRASH_ACTION -> crash(context) + TOAST_ACTION -> toast(context) + } + } + + fun crash(context: Context) { + Toast.makeText(context, "KABOOM!", Toast.LENGTH_LONG).show() + throw RuntimeException("CRASH_BROADCAST") + } + + fun toast(context: Context) { + Toast.makeText(context, "Cheers!", Toast.LENGTH_LONG).show() + } + + 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/CrashWidgetProvider.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashWidgetProvider.kt new file mode 100644 index 00000000000..0661c9e5164 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashWidgetProvider.kt @@ -0,0 +1,78 @@ +/* + * 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.testing.sessions + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.icu.text.SimpleDateFormat +import android.widget.RemoteViews +import com.google.firebase.FirebaseApp +import java.util.Date +import java.util.Locale + +/** Provides homescreen widget for the test app. */ +class CrashWidgetProvider : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + FirebaseApp.initializeApp(context) + + appWidgetIds.forEach { appWidgetId -> + // Get the layout for the widget and attach an on-click listener + // to the button. + val views: RemoteViews = + RemoteViews(context.packageName, R.layout.crash_widget).apply { + setOnClickPendingIntent(R.id.widgetCrashButton, getPendingCrashIntent(context)) + setTextViewText(R.id.widgetTimeText, DATE_FMT.format(Date())) + } + + // Tell the AppWidgetManager to perform an update on the current + // widget. + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + + override fun onReceive(context: Context, intent: Intent): Unit { + super.onReceive(context, intent) + + if (CRASH_BUTTON_CLICK == intent.getAction()) { + throw RuntimeException("CRASHED FROM WIDGET") + } + } + + fun getPendingCrashIntent(context: Context): PendingIntent { + val intent = Intent(context, CrashWidgetProvider::class.java) + intent.setAction(CRASH_BUTTON_CLICK) + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + companion object { + val CRASH_BUTTON_CLICK = "widgetCrashButtonClick" + val DATE_FMT = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + } +} 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 new file mode 100644 index 00000000000..a190a39cd2b --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt @@ -0,0 +1,103 @@ +/* + * 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.testing.sessions + +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.icu.text.SimpleDateFormat +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.testing.sessions.databinding.FragmentFirstBinding +import java.util.Date +import java.util.Locale + +/** A simple [Fragment] subclass as the default destination in the navigation. */ +class FirstFragment : Fragment() { + val crashlytics = FirebaseCrashlytics.getInstance() + + private var _binding: FragmentFirstBinding? = null + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding + get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + _binding = FragmentFirstBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.buttonCrash.setOnClickListener { throw RuntimeException("CRASHED") } + binding.buttonNonFatal.setOnClickListener { + crashlytics.recordException(IllegalStateException()) + } + binding.buttonAnr.setOnClickListener { + while (true) { + Thread.sleep(10_000) + } + } + binding.buttonForegroundProcess.setOnClickListener { + if (binding.buttonForegroundProcess.getText().startsWith("Start")) { + ForegroundService.startService( + getContext()!!, + "Starting service at ${DATE_FMT.format(Date())}" + ) + binding.buttonForegroundProcess.setText("Stop foreground service") + } else { + ForegroundService.stopService(getContext()!!) + binding.buttonForegroundProcess.setText("Start foreground service") + } + } + binding.startSplitscreen.setOnClickListener { + val intent = Intent(getContext()!!, SecondActivity::class.java) + intent.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_LAUNCH_ADJACENT) + startActivity(intent) + } + binding.startSplitscreenSame.setOnClickListener { + val intent = Intent(getContext()!!, MainActivity::class.java) + intent.addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_LAUNCH_ADJACENT) + startActivity(intent) + } + binding.nextActivityButton.setOnClickListener { + val intent = Intent(getContext()!!, SecondActivity::class.java) + intent.addFlags(FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + val DATE_FMT = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + } +} 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 new file mode 100644 index 00000000000..21f99e70d91 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt @@ -0,0 +1,118 @@ +/* + * 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.testing.sessions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.google.firebase.FirebaseApp + +/** */ +class ForegroundService : Service() { + private val CHANNEL_ID = "CrashForegroundService" + val receiver = CrashBroadcastReceiver() + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Initializing app From ForegroundSErvice") + FirebaseApp.initializeApp(this) + createNotificationChannel() + val pending = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val crashIntent = Intent(CrashBroadcastReceiver.CRASH_ACTION) + val toastIntent = Intent(CrashBroadcastReceiver.TOAST_ACTION) + + val pendingCrash = + PendingIntent.getBroadcast(this, 0, crashIntent, PendingIntent.FLAG_IMMUTABLE) + val pendingToast = + PendingIntent.getBroadcast(this, 0, toastIntent, PendingIntent.FLAG_IMMUTABLE) + val pendingMsg = + PendingIntent.getActivity( + this, + 0, + Intent(this, SecondActivity::class.java).setAction("MESSAGE"), + PendingIntent.FLAG_IMMUTABLE + ) + + val notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Crash Test Notification Widget") + .setContentText(intent?.getStringExtra("inputExtra")) + .setContentIntent(pending) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setTicker("Crash Notification Widget Ticker") + .addAction(R.drawable.ic_launcher_foreground, "CRASH!", pendingCrash) + .addAction(R.drawable.ic_launcher_foreground, "TOAST!", pendingToast) + .addAction(R.drawable.ic_launcher_foreground, "Send Message", pendingMsg) + .build() + + startForeground(1, notification) + return START_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "OnDestroy for ForegroundService") + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = + NotificationChannel( + CHANNEL_ID, + "Foreground Service Channel", + NotificationManager.IMPORTANCE_DEFAULT + ) + val manager = getSystemService(NotificationManager::class.java) + manager!!.createNotificationChannel(serviceChannel) + } + } + + companion object { + val TAG = "CrashWidgetForegroundService" + + fun startService(context: Context, message: String) { + Log.i(TAG, "Starting foreground serice") + ContextCompat.startForegroundService( + context, + Intent(context, ForegroundService::class.java).putExtra("inputExtra", message) + ) + } + + fun stopService(context: Context) { + Log.i(TAG, "Stopping serice") + context.stopService(Intent(context, ForegroundService::class.java)) + } + } +} diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MainActivity.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MainActivity.kt index 9db0be0ef7c..ac41d11d73e 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MainActivity.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MainActivity.kt @@ -17,14 +17,16 @@ package com.google.firebase.testing.sessions import android.os.Bundle -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity +import com.google.firebase.testing.sessions.databinding.ActivityMainBinding + +class MainActivity : BaseActivity() { + + private lateinit var binding: ActivityMainBinding -class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - findViewById(R.id.greeting_text).text = getText(R.string.firebase_greetings) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) } } 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 new file mode 100644 index 00000000000..aff1bbacd91 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt @@ -0,0 +1,48 @@ +/* + * 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.testing.sessions + +import android.app.ActivityManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import android.util.Log +import android.widget.Button + +/** Second activity from the MainActivity that runs on a different process. */ +class SecondActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_second) + findViewById