diff --git a/firebase-sessions/CHANGELOG.md b/firebase-sessions/CHANGELOG.md index d604327ca3d..f1ef3ba1fea 100644 --- a/firebase-sessions/CHANGELOG.md +++ b/firebase-sessions/CHANGELOG.md @@ -1,36 +1,20 @@ # Unreleased * [changed] Added internal api for Crashlytics to notify Sessions of crash events +* [changed] Use multi-process DataStore instead of Preferences DataStore +* [changed] Update the heuristic to detect cold app starts # 2.1.1 * [unchanged] Updated to keep SDK versions aligned. - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.1.0 * [changed] Add warning for known issue b/328687152 * [changed] Use Dagger for dependency injection * [changed] Updated datastore dependency to v1.1.3 to fix [CVE-2024-7254](https://github.com/advisories/GHSA-735f-pc8j-v9w8). - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.0.9 * [fixed] Make AQS resilient to background init in multi-process apps. - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.0.7 * [fixed] Removed extraneous logs that risk leaking internal identifiers. diff --git a/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt b/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt index fac6f1a4977..2d517a231e4 100644 --- a/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt +++ b/firebase-sessions/benchmark/src/main/kotlin/com/google/firebase/benchmark/sessions/StartupBenchmark.kt @@ -20,6 +20,8 @@ import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.StartupTimingMetric import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.FileInputStream import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -31,7 +33,7 @@ class StartupBenchmark { @Test fun startup() = benchmarkRule.measureRepeated( - packageName = "com.google.firebase.testing.sessions", + packageName = PACKAGE_NAME, metrics = listOf(StartupTimingMetric()), iterations = 5, startupMode = StartupMode.COLD, @@ -39,4 +41,35 @@ class StartupBenchmark { pressHome() startActivityAndWait() } + + @Test + fun startup_clearAppData() = + benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + iterations = 5, + startupMode = StartupMode.COLD, + setupBlock = { clearAppData(packageName) }, + ) { + pressHome() + startActivityAndWait() + } + + private fun clearAppData(packageName: String) { + val fileDescriptor = + InstrumentationRegistry.getInstrumentation() + .uiAutomation + .executeShellCommand("pm clear $packageName") + val fileInputStream = FileInputStream(fileDescriptor.fileDescriptor) + // Read the output to ensure the app data was cleared successfully + val result = fileInputStream.bufferedReader().use { it.readText().trim() } + fileDescriptor.close() + if (result != "Success") { + throw IllegalStateException("Unable to clear app data for $packageName - $result") + } + } + + private companion object { + const val PACKAGE_NAME = "com.google.firebase.testing.sessions" + } } diff --git a/firebase-sessions/firebase-sessions.gradle.kts b/firebase-sessions/firebase-sessions.gradle.kts index b136a281660..6c4b56a1c3b 100644 --- a/firebase-sessions/firebase-sessions.gradle.kts +++ b/firebase-sessions/firebase-sessions.gradle.kts @@ -21,6 +21,7 @@ plugins { id("firebase-vendor") id("kotlin-android") id("kotlin-kapt") + id("kotlinx-serialization") } firebaseLibrary { @@ -28,7 +29,11 @@ firebaseLibrary { testLab.enabled = true publishJavadoc = false - releaseNotes { enabled.set(false) } + + releaseNotes { + enabled = false + hasKTX = false + } } android { @@ -76,7 +81,8 @@ dependencies { implementation("com.google.android.datatransport:transport-api:3.2.0") implementation(libs.javax.inject) implementation(libs.androidx.annotation) - implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore) + implementation(libs.kotlinx.serialization.json) vendor(libs.dagger.dagger) { exclude(group = "javax.inject", module = "javax.inject") } diff --git a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt b/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt index 1cf67e0c5e1..33799a64d77 100644 --- a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt +++ b/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/FirebaseSessionsTests.kt @@ -20,12 +20,10 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseOptions import com.google.firebase.initialize import com.google.firebase.sessions.settings.SessionsSettings -import org.junit.After -import org.junit.Before +import org.junit.BeforeClass import org.junit.Test import org.junit.runner.RunWith @@ -36,23 +34,6 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class FirebaseSessionsTests { - @Before - fun setUp() { - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(APP_ID) - .setApiKey(API_KEY) - .setProjectId(PROJECT_ID) - .build() - ) - } - - @After - fun cleanUp() { - FirebaseApp.clearInstancesForTest() - } - @Test fun firebaseSessionsDoesInitialize() { assertThat(FirebaseSessions.instance).isNotNull() @@ -61,7 +42,6 @@ class FirebaseSessionsTests { @Test fun firebaseSessionsDependenciesDoInitialize() { assertThat(SessionFirelogPublisher.instance).isNotNull() - assertThat(SessionGenerator.instance).isNotNull() assertThat(SessionsSettings.instance).isNotNull() } @@ -69,5 +49,18 @@ class FirebaseSessionsTests { private const val APP_ID = "1:1:android:1a" private const val API_KEY = "API-KEY-API-KEY-API-KEY-API-KEY-API-KEY" private const val PROJECT_ID = "PROJECT-ID" + + @BeforeClass + @JvmStatic + fun setUp() { + Firebase.initialize( + ApplicationProvider.getApplicationContext(), + FirebaseOptions.Builder() + .setApplicationId(APP_ID) + .setApiKey(API_KEY) + .setProjectId(PROJECT_ID) + .build(), + ) + } } } diff --git a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt b/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt deleted file mode 100644 index 49106b742af..00000000000 --- a/firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.rule.ServiceTestRule -import com.google.common.truth.Truth.assertThat -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class SessionLifecycleServiceBinderTest { - @get:Rule val serviceRule = ServiceTestRule() - - @Test - fun bindSessionLifecycleService() { - val serviceConnection = - object : ServiceConnection { - var connected: Boolean = false - - override fun onServiceConnected(className: ComponentName?, serviceBinder: IBinder?) { - connected = true - } - - override fun onServiceDisconnected(className: ComponentName?) { - connected = false - } - } - - val sessionLifecycleServiceIntent = - Intent(ApplicationProvider.getApplicationContext(), SessionLifecycleService::class.java) - - serviceRule.bindService( - sessionLifecycleServiceIntent, - serviceConnection, - Context.BIND_IMPORTANT or Context.BIND_AUTO_CREATE, - ) - - assertThat(serviceConnection.connected).isTrue() - } -} diff --git a/firebase-sessions/src/main/AndroidManifest.xml b/firebase-sessions/src/main/AndroidManifest.xml index 181dcb1eee7..f0f9609f4e7 100644 --- a/firebase-sessions/src/main/AndroidManifest.xml +++ b/firebase-sessions/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + @@ -19,11 +18,8 @@ - + android:exported="false" + android:name="com.google.firebase.components.ComponentDiscoveryService"> diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt index 496cc70d36d..25656396fff 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/EventGDTLogger.kt @@ -21,6 +21,7 @@ import com.google.android.datatransport.Encoding import com.google.android.datatransport.Event import com.google.android.datatransport.TransportFactory import com.google.firebase.inject.Provider +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import javax.inject.Inject import javax.inject.Singleton @@ -61,8 +62,6 @@ constructor(private val transportFactoryProvider: Provider) : } companion object { - private const val TAG = "EventGDTLogger" - private const val AQS_LOG_SOURCE = "FIREBASE_APPQUALITY_SESSION" } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt index 18b9961724b..272de8d339c 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt @@ -38,14 +38,14 @@ constructor( private val firebaseApp: FirebaseApp, private val settings: SessionsSettings, @Background backgroundDispatcher: CoroutineContext, - lifecycleServiceBinder: SessionLifecycleServiceBinder, + sessionsActivityLifecycleCallbacks: SessionsActivityLifecycleCallbacks, ) { init { - Log.d(TAG, "Initializing Firebase Sessions SDK.") + Log.d(TAG, "Initializing Firebase Sessions ${BuildConfig.VERSION_NAME}.") val appContext = firebaseApp.applicationContext.applicationContext if (appContext is Application) { - appContext.registerActivityLifecycleCallbacks(SessionsActivityLifecycleCallbacks) + appContext.registerActivityLifecycleCallbacks(sessionsActivityLifecycleCallbacks) CoroutineScope(backgroundDispatcher).launch { val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() @@ -56,16 +56,12 @@ constructor( if (!settings.sessionsEnabled) { Log.d(TAG, "Sessions SDK disabled. Not listening to lifecycle events.") } else { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher) - lifecycleClient.bindToService(lifecycleServiceBinder) - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - firebaseApp.addLifecycleEventListener { _, _ -> Log.w( TAG, "FirebaseApp instance deleted. Sessions library will stop collecting data.", ) - SessionsActivityLifecycleCallbacks.lifecycleClient = null + sessionsActivityLifecycleCallbacks.onAppDelete() } } } @@ -79,7 +75,7 @@ constructor( } companion object { - private const val TAG = "FirebaseSessions" + internal const val TAG = "FirebaseSessions" val instance: FirebaseSessions get() = Firebase.app[FirebaseSessions::class.java] diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt index 5680c9cc0ec..70f818c570f 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt @@ -18,37 +18,40 @@ package com.google.firebase.sessions import android.content.Context import android.util.Log +import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.MultiProcessDataStoreFactory +import androidx.datastore.core.Serializer import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.datastore.dataStoreFile import com.google.android.datatransport.TransportFactory import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background import com.google.firebase.annotations.concurrent.Blocking import com.google.firebase.inject.Provider import com.google.firebase.installations.FirebaseInstallationsApi -import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.settings.CrashlyticsSettingsFetcher import com.google.firebase.sessions.settings.LocalOverrideSettings import com.google.firebase.sessions.settings.RemoteSettings import com.google.firebase.sessions.settings.RemoteSettingsFetcher +import com.google.firebase.sessions.settings.SessionConfigs +import com.google.firebase.sessions.settings.SessionConfigsSerializer import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.settings.SettingsCache +import com.google.firebase.sessions.settings.SettingsCacheImpl import com.google.firebase.sessions.settings.SettingsProvider import dagger.Binds import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides +import java.io.File import javax.inject.Qualifier import javax.inject.Singleton import kotlin.coroutines.CoroutineContext - -@Qualifier internal annotation class SessionConfigsDataStore - -@Qualifier internal annotation class SessionDetailsDataStore +import kotlinx.coroutines.CoroutineScope @Qualifier internal annotation class LocalOverrideSettingsProvider @@ -64,10 +67,10 @@ import kotlin.coroutines.CoroutineContext internal interface FirebaseSessionsComponent { val firebaseSessions: FirebaseSessions - val sessionDatastore: SessionDatastore val sessionFirelogPublisher: SessionFirelogPublisher val sessionGenerator: SessionGenerator val sessionsSettings: SessionsSettings + val sharedSessionRepository: SharedSessionRepository @Component.Builder interface Builder { @@ -93,18 +96,10 @@ internal interface FirebaseSessionsComponent { interface MainModule { @Binds @Singleton fun eventGDTLoggerInterface(impl: EventGDTLogger): EventGDTLoggerInterface - @Binds @Singleton fun sessionDatastore(impl: SessionDatastoreImpl): SessionDatastore - @Binds @Singleton fun sessionFirelogPublisher(impl: SessionFirelogPublisherImpl): SessionFirelogPublisher - @Binds - @Singleton - fun sessionLifecycleServiceBinder( - impl: SessionLifecycleServiceBinderImpl - ): SessionLifecycleServiceBinder - @Binds @Singleton fun crashlyticsSettingsFetcher(impl: RemoteSettingsFetcher): CrashlyticsSettingsFetcher @@ -119,9 +114,15 @@ internal interface FirebaseSessionsComponent { @RemoteSettingsProvider fun remoteSettings(impl: RemoteSettings): SettingsProvider - companion object { - private const val TAG = "FirebaseSessions" + @Binds @Singleton fun settingsCache(impl: SettingsCacheImpl): SettingsCache + @Binds + @Singleton + fun sharedSessionRepository(impl: SharedSessionRepositoryImpl): SharedSessionRepository + + @Binds @Singleton fun processDataManager(impl: ProcessDataManagerImpl): ProcessDataManager + + companion object { @Provides @Singleton fun timeProvider(): TimeProvider = TimeProviderImpl @Provides @Singleton fun uuidGenerator(): UuidGenerator = UuidGeneratorImpl @@ -133,30 +134,68 @@ internal interface FirebaseSessionsComponent { @Provides @Singleton - @SessionConfigsDataStore - fun sessionConfigsDataStore(appContext: Context): DataStore = - PreferenceDataStoreFactory.create( + fun sessionConfigsDataStore( + appContext: Context, + @Blocking blockingDispatcher: CoroutineContext, + ): DataStore = + createDataStore( + serializer = SessionConfigsSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> - Log.w(TAG, "CorruptionException in settings DataStore in ${getProcessName()}.", ex) - emptyPreferences() - } - ) { - appContext.preferencesDataStoreFile(SessionDataStoreConfigs.SETTINGS_CONFIG_NAME) - } + Log.w(TAG, "CorruptionException in session configs DataStore", ex) + SessionConfigsSerializer.defaultValue + }, + scope = CoroutineScope(blockingDispatcher), + produceFile = { appContext.dataStoreFile("aqs/sessionConfigsDataStore.data") }, + ) @Provides @Singleton - @SessionDetailsDataStore - fun sessionDetailsDataStore(appContext: Context): DataStore = - PreferenceDataStoreFactory.create( + fun sessionDataStore( + appContext: Context, + @Blocking blockingDispatcher: CoroutineContext, + sessionDataSerializer: SessionDataSerializer, + ): DataStore = + createDataStore( + serializer = sessionDataSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> - Log.w(TAG, "CorruptionException in sessions DataStore in ${getProcessName()}.", ex) - emptyPreferences() - } - ) { - appContext.preferencesDataStoreFile(SessionDataStoreConfigs.SESSIONS_CONFIG_NAME) + Log.w(TAG, "CorruptionException in session data DataStore", ex) + sessionDataSerializer.defaultValue + }, + scope = CoroutineScope(blockingDispatcher), + produceFile = { appContext.dataStoreFile("aqs/sessionDataStore.data") }, + ) + + private fun createDataStore( + serializer: Serializer, + corruptionHandler: ReplaceFileCorruptionHandler, + migrations: List> = listOf(), + scope: CoroutineScope, + produceFile: () -> File, + ): DataStore = + if (loadDataStoreSharedCounter()) { + MultiProcessDataStoreFactory.create( + serializer, + corruptionHandler, + migrations, + scope, + produceFile, + ) + } else { + DataStoreFactory.create(serializer, corruptionHandler, migrations, scope, produceFile) + } + + /** This native library in unavailable in some conditions, for example, Robolectric tests */ + // TODO(mrober): Remove this when b/392626815 is resolved + private fun loadDataStoreSharedCounter(): Boolean = + try { + System.loadLibrary("datastore_shared_counter") + true + } catch (_: UnsatisfiedLinkError) { + false + } catch (_: SecurityException) { + false } } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt index 5cb8de7a182..3d66959bdcd 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsRegistrar.kt @@ -19,7 +19,7 @@ package com.google.firebase.sessions import android.content.Context import android.util.Log import androidx.annotation.Keep -import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.core.MultiProcessDataStoreFactory import com.google.android.datatransport.TransportFactory import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background @@ -31,6 +31,7 @@ import com.google.firebase.components.Qualified.qualified import com.google.firebase.components.Qualified.unqualified import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.platforminfo.LibraryVersionComponent +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import kotlinx.coroutines.CoroutineDispatcher /** @@ -71,7 +72,6 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { ) private companion object { - const val TAG = "FirebaseSessions" const val LIBRARY_NAME = "fire-sessions" val appContext = unqualified(Context::class.java) @@ -84,7 +84,7 @@ internal class FirebaseSessionsRegistrar : ComponentRegistrar { init { try { - ::preferencesDataStore.javaClass + MultiProcessDataStoreFactory.javaClass } catch (ex: NoClassDefFoundError) { Log.w( TAG, diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt index 0df42fda953..69a7c4f4330 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/InstallationId.kt @@ -18,13 +18,12 @@ package com.google.firebase.sessions import android.util.Log import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import kotlinx.coroutines.tasks.await /** Provides the Firebase installation id and Firebase authentication token. */ internal class InstallationId private constructor(val fid: String, val authToken: String) { companion object { - private const val TAG = "InstallationId" - suspend fun create(firebaseInstallations: FirebaseInstallationsApi): InstallationId { // Fetch the auth token first, so the fid will be validated. val authToken: String = diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt new file mode 100644 index 00000000000..295b6550ed7 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import android.content.Context +import android.os.Process +import javax.inject.Inject +import javax.inject.Singleton + +/** Manage process data, used for detecting cold app starts. */ +internal interface ProcessDataManager { + /** This process's name. */ + val myProcessName: String + + /** This process's pid. */ + val myPid: Int + + /** An in-memory uuid to uniquely identify this instance of this process, not the uid. */ + val myUuid: String + + /** Checks if this is a cold app start, meaning all processes in the mapping table are stale. */ + fun isColdStart(processDataMap: Map): Boolean + + /** Checks if this process is stale. */ + fun isMyProcessStale(processDataMap: Map): Boolean + + /** Call to notify the process data manager that a session has been generated. */ + fun onSessionGenerated() + + /** Update the mapping of the current processes with data about this process. */ + fun updateProcessDataMap(processDataMap: Map?): Map + + /** Generate a new mapping of process data about this process only. */ + fun generateProcessDataMap(): Map = updateProcessDataMap(emptyMap()) +} + +@Singleton +internal class ProcessDataManagerImpl +@Inject +constructor(private val appContext: Context, uuidGenerator: UuidGenerator) : ProcessDataManager { + /** + * This process's name. + * + * This value is cached, so will not reflect changes to the process name during runtime. + */ + override val myProcessName: String by lazy { myProcessDetails.processName } + + override val myPid = Process.myPid() + + override val myUuid: String by lazy { uuidGenerator.next().toString() } + + private val myProcessDetails by lazy { ProcessDetailsProvider.getMyProcessDetails(appContext) } + + private var hasGeneratedSession: Boolean = false + + override fun isColdStart(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + // This process has been notified that a session was generated, so cannot be a cold start + return false + } + + // A cold start is when all app processes are stale + return getAppProcessDetails() + .mapNotNull { processDetails -> + processDataMap[processDetails.processName]?.let { processData -> + Pair(processDetails, processData) + } + } + .all { (processDetails, processData) -> isProcessStale(processDetails, processData) } + } + + override fun isMyProcessStale(processDataMap: Map): Boolean { + val myProcessData = processDataMap[myProcessName] ?: return true + return myProcessData.pid != myPid || myProcessData.uuid != myUuid + } + + override fun onSessionGenerated() { + hasGeneratedSession = true + } + + override fun updateProcessDataMap( + processDataMap: Map? + ): Map = + processDataMap + ?.toMutableMap() + ?.apply { this[myProcessName] = ProcessData(Process.myPid(), myUuid) } + ?.toMap() + ?: mapOf(myProcessName to ProcessData(Process.myPid(), myUuid)) + + /** Gets the current details for all of the app's running processes. */ + private fun getAppProcessDetails() = ProcessDetailsProvider.getAppProcessDetails(appContext) + + /** + * Returns true if the process is stale, meaning the persisted process data does not match the + * running process details. + * + * The [processDetails] is the running process details, and [processData] is the persisted data. + */ + private fun isProcessStale(processDetails: ProcessDetails, processData: ProcessData): Boolean = + if (myProcessName == processDetails.processName) { + // For this process, check pid and uuid + processDetails.pid != processData.pid || myUuid != processData.uuid + } else { + // For other processes, only check pid to avoid inter-process communication + // It is very unlikely for there to be a pid collision + processDetails.pid != processData.pid + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt index 65d1dfbbc60..39a3c03ed17 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDetailsProvider.kt @@ -23,13 +23,9 @@ import android.os.Build import android.os.Process import com.google.android.gms.common.util.ProcessUtils -/** - * Provider of ProcessDetails. - * - * @hide - */ +/** Provide [ProcessDetails] for all app processes. */ internal object ProcessDetailsProvider { - /** Gets the details for all of this app's running processes. */ + /** Gets the details for all the app's running processes. */ fun getAppProcessDetails(context: Context): List { val appUid = context.applicationInfo.uid val defaultProcessName = context.applicationInfo.processName @@ -53,27 +49,19 @@ internal object ProcessDetailsProvider { } /** - * Gets this app's current process details. + * Gets this process's details. * - * If the current process details are not found for whatever reason, returns process details with - * just the current process name and pid set. + * If this process's full details are not found for whatever reason, returns process details with + * just the process name and pid set. */ - fun getCurrentProcessDetails(context: Context): ProcessDetails { + fun getMyProcessDetails(context: Context): ProcessDetails { val pid = Process.myPid() return getAppProcessDetails(context).find { it.pid == pid } - ?: buildProcessDetails(getProcessName(), pid) + ?: ProcessDetails(getProcessName(), pid, importance = 0, isDefaultProcess = false) } - /** Builds a ProcessDetails object. */ - private fun buildProcessDetails( - processName: String, - pid: Int = 0, - importance: Int = 0, - isDefaultProcess: Boolean = false - ) = ProcessDetails(processName, pid, importance, isDefaultProcess) - /** Gets the app's current process name. If it could not be found, returns an empty string. */ - internal fun getProcessName(): String { + private fun getProcessName(): String { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) { return Process.myProcessName() } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt new file mode 100644 index 00000000000..8af2eee544d --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Session data to be persisted. */ +@Serializable +internal data class SessionData( + val sessionDetails: SessionDetails, + val backgroundTime: Time? = null, + val processDataMap: Map? = null, +) + +/** Data about a process, for persistence. */ +@Serializable internal data class ProcessData(val pid: Int, val uuid: String) + +/** DataStore json [Serializer] for [SessionData]. */ +@Singleton +internal class SessionDataSerializer +@Inject +constructor(private val sessionGenerator: SessionGenerator) : Serializer { + override val defaultValue: SessionData + get() = SessionData(sessionGenerator.generateNewSession(currentSession = null)) + + override suspend fun readFrom(input: InputStream): SessionData = + try { + Json.decodeFromString(input.readBytes().decodeToString()) + } catch (ex: Exception) { + throw CorruptionException("Cannot parse session data", ex) + } + + override suspend fun writeTo(t: SessionData, output: OutputStream) { + @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls + output.write(Json.encodeToString(SessionData.serializer(), t).encodeToByteArray()) + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt deleted file mode 100644 index 109e980e666..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.util.Base64 -import com.google.firebase.sessions.settings.SessionsSettings - -/** - * Util object for handling DataStore configs in multi-process apps safely. - * - * This can be removed when datastore-preferences:1.1.0 becomes stable. - */ -internal object SessionDataStoreConfigs { - /** Sanitized process name to use in config filenames. */ - private val PROCESS_NAME = - Base64.encodeToString( - ProcessDetailsProvider.getProcessName().encodeToByteArray(), - Base64.NO_WRAP or Base64.URL_SAFE, // URL safe is also filename safe. - ) - - /** Config name for [SessionDatastore] */ - val SESSIONS_CONFIG_NAME = "firebase_session_${PROCESS_NAME}_data" - - /** Config name for [SessionsSettings] */ - val SETTINGS_CONFIG_NAME = "firebase_session_${PROCESS_NAME}_settings" -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt deleted file mode 100644 index 2c4f243f942..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.util.Log -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey -import com.google.firebase.Firebase -import com.google.firebase.annotations.concurrent.Background -import com.google.firebase.app -import java.io.IOException -import java.util.concurrent.atomic.AtomicReference -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -/** Datastore for sessions information */ -internal data class FirebaseSessionsData(val sessionId: String?) - -/** Handles reading to and writing from the [DataStore]. */ -internal interface SessionDatastore { - /** Stores a new session ID value in the [DataStore] */ - fun updateSessionId(sessionId: String) - - /** - * Gets the currently stored session ID from the [DataStore]. This will be null if no session has - * been stored previously. - */ - fun getCurrentSessionId(): String? - - companion object { - val instance: SessionDatastore - get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionDatastore - } -} - -@Singleton -internal class SessionDatastoreImpl -@Inject -constructor( - @Background private val backgroundDispatcher: CoroutineContext, - @SessionDetailsDataStore private val dataStore: DataStore, -) : SessionDatastore { - - /** Most recent session from datastore is updated asynchronously whenever it changes */ - private val currentSessionFromDatastore = AtomicReference() - - private object FirebaseSessionDataKeys { - val SESSION_ID = stringPreferencesKey("session_id") - } - - private val firebaseSessionDataFlow: Flow = - dataStore.data - .catch { exception -> - Log.e(TAG, "Error reading stored session data.", exception) - emit(emptyPreferences()) - } - .map { preferences -> mapSessionsData(preferences) } - - init { - CoroutineScope(backgroundDispatcher).launch { - firebaseSessionDataFlow.collect { currentSessionFromDatastore.set(it) } - } - } - - override fun updateSessionId(sessionId: String) { - CoroutineScope(backgroundDispatcher).launch { - try { - dataStore.edit { preferences -> - preferences[FirebaseSessionDataKeys.SESSION_ID] = sessionId - } - } catch (e: IOException) { - Log.w(TAG, "Failed to update session Id: $e") - } - } - } - - override fun getCurrentSessionId() = currentSessionFromDatastore.get()?.sessionId - - private fun mapSessionsData(preferences: Preferences): FirebaseSessionsData = - FirebaseSessionsData(preferences[FirebaseSessionDataKeys.SESSION_ID]) - - private companion object { - private const val TAG = "FirebaseSessionsRepo" - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt new file mode 100644 index 00000000000..c5f71fb65e5 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import kotlinx.serialization.Serializable + +/** Details about the current session. */ +@Serializable +internal data class SessionDetails( + val sessionId: String, + val firstSessionId: String, + val sessionIndex: Int, + val sessionStartTimestampUs: Long, +) diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt index 25b3cbeb15d..864b393d64b 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt @@ -63,7 +63,6 @@ internal object SessionEvents { fun getApplicationInfo(firebaseApp: FirebaseApp): ApplicationInfo { val context = firebaseApp.applicationContext val packageName = context.packageName - @Suppress("DEPRECATION") // TODO(mrober): Use ApplicationInfoFlags when target sdk set to 33 val packageInfo = context.packageManager.getPackageInfo(packageName, 0) val buildVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -84,7 +83,7 @@ internal object SessionEvents { versionName = packageInfo.versionName ?: buildVersion, appBuildVersion = buildVersion, deviceManufacturer = Build.MANUFACTURER, - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext), + ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext), ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext), ), ) diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt index 6e4b6153f8d..21cb379cacb 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt @@ -22,6 +22,7 @@ import com.google.firebase.FirebaseApp import com.google.firebase.annotations.concurrent.Background import com.google.firebase.app import com.google.firebase.installations.FirebaseInstallationsApi +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.settings.SessionsSettings import javax.inject.Inject @@ -34,7 +35,7 @@ import kotlinx.coroutines.launch internal fun interface SessionFirelogPublisher { /** Asynchronously logs the session represented by the given [SessionDetails] to Firelog. */ - fun logSession(sessionDetails: SessionDetails) + fun mayLogSession(sessionDetails: SessionDetails) companion object { val instance: SessionFirelogPublisher @@ -64,7 +65,7 @@ constructor( * This will pull all the necessary information about the device in order to create a full * [SessionEvent], and then upload that through the Firelog interface. */ - override fun logSession(sessionDetails: SessionDetails) { + override fun mayLogSession(sessionDetails: SessionDetails) { CoroutineScope(backgroundDispatcher).launch { if (shouldLogSession()) { val installationId = InstallationId.create(firebaseInstallations) @@ -94,13 +95,16 @@ constructor( /** Determines if the SDK should log a session to Firelog. */ private suspend fun shouldLogSession(): Boolean { - Log.d(TAG, "Data Collection is enabled for at least one Subscriber") - + val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() + if (subscribers.values.none { it.isDataCollectionEnabled }) { + Log.d(TAG, "Sessions SDK disabled through data collection. Events will not be sent.") + return false + } // This will cause remote settings to be fetched if the cache is expired. sessionSettings.updateSettings() if (!sessionSettings.sessionsEnabled) { - Log.d(TAG, "Sessions SDK disabled. Events will not be sent.") + Log.d(TAG, "Sessions SDK disabled through settings API. Events will not be sent.") return false } @@ -119,8 +123,6 @@ constructor( } internal companion object { - private const val TAG = "SessionFirelogPublisher" - private val randomValueForSampling: Double = Math.random() } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt index 4c4775e8b24..888b3d4729b 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt @@ -16,22 +16,9 @@ package com.google.firebase.sessions -import com.google.errorprone.annotations.CanIgnoreReturnValue -import com.google.firebase.Firebase -import com.google.firebase.app import javax.inject.Inject import javax.inject.Singleton -/** - * [SessionDetails] is a data class responsible for storing information about the current Session. - */ -internal data class SessionDetails( - val sessionId: String, - val firstSessionId: String, - val sessionIndex: Int, - val sessionStartTimestampUs: Long, -) - /** * The [SessionGenerator] is responsible for generating the Session ID, and keeping the * [SessionDetails] up to date with the latest values. @@ -40,35 +27,20 @@ internal data class SessionDetails( internal class SessionGenerator @Inject constructor(private val timeProvider: TimeProvider, private val uuidGenerator: UuidGenerator) { - private val firstSessionId = generateSessionId() - private var sessionIndex = -1 - - /** The current generated session, must not be accessed before calling [generateNewSession]. */ - lateinit var currentSession: SessionDetails - private set - - /** Returns if a session has been generated. */ - val hasGenerateSession: Boolean - get() = ::currentSession.isInitialized - - /** Generates a new session. The first session's sessionId will match firstSessionId. */ - @CanIgnoreReturnValue - fun generateNewSession(): SessionDetails { - sessionIndex++ - currentSession = - SessionDetails( - sessionId = if (sessionIndex == 0) firstSessionId else generateSessionId(), - firstSessionId, - sessionIndex, - sessionStartTimestampUs = timeProvider.currentTimeUs(), - ) - return currentSession + /** + * Generates a new session. + * + * If a current session is provided, will maintain the first session id and appropriate index. + */ + fun generateNewSession(currentSession: SessionDetails?): SessionDetails { + val newSessionId = generateSessionId() + return SessionDetails( + sessionId = newSessionId, + firstSessionId = currentSession?.firstSessionId ?: newSessionId, + sessionIndex = currentSession?.sessionIndex?.inc() ?: 0, + sessionStartTimestampUs = timeProvider.currentTime().us, + ) } private fun generateSessionId() = uuidGenerator.next().toString().replace("-", "").lowercase() - - internal companion object { - val instance: SessionGenerator - get() = Firebase.app[FirebaseSessionsComponent::class.java].sessionGenerator - } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt deleted file mode 100644 index 900068af00d..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.os.Message -import android.os.Messenger -import android.os.RemoteException -import android.util.Log -import com.google.errorprone.annotations.CanIgnoreReturnValue -import com.google.firebase.sessions.api.FirebaseSessionsDependencies -import com.google.firebase.sessions.api.SessionSubscriber -import java.util.concurrent.LinkedBlockingDeque -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * Client for binding to the [SessionLifecycleService]. This client will receive updated sessions - * through a callback whenever a new session is generated by the service, or after the initial - * binding. - * - * Note: this client will be connected in every application process that uses Firebase, and is - * intended to maintain that connection for the lifetime of the process. - */ -internal class SessionLifecycleClient(private val backgroundDispatcher: CoroutineContext) { - - private var service: Messenger? = null - private var serviceBound: Boolean = false - private val queuedMessages = LinkedBlockingDeque(MAX_QUEUED_MESSAGES) - - /** - * The callback class that will be used to receive updated session events from the - * [SessionLifecycleService]. - */ - internal class ClientUpdateHandler(private val backgroundDispatcher: CoroutineContext) : - Handler(Looper.getMainLooper()) { - - override fun handleMessage(msg: Message) { - when (msg.what) { - SessionLifecycleService.SESSION_UPDATED -> - handleSessionUpdate( - msg.data?.getString(SessionLifecycleService.SESSION_UPDATE_EXTRA) ?: "" - ) - else -> { - Log.w(TAG, "Received unexpected event from the SessionLifecycleService: $msg") - super.handleMessage(msg) - } - } - } - - private fun handleSessionUpdate(sessionId: String) { - Log.d(TAG, "Session update received.") - - CoroutineScope(backgroundDispatcher).launch { - FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> - // Notify subscribers, regardless of sampling and data collection state. - subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) - Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId") - } - } - } - } - - /** The connection object to the [SessionLifecycleService]. */ - private val serviceConnection = - object : ServiceConnection { - override fun onServiceConnected(className: ComponentName?, serviceBinder: IBinder?) { - Log.d(TAG, "Connected to SessionLifecycleService. Queue size ${queuedMessages.size}") - service = Messenger(serviceBinder) - serviceBound = true - sendLifecycleEvents(drainQueue()) - } - - override fun onServiceDisconnected(className: ComponentName?) { - Log.d(TAG, "Disconnected from SessionLifecycleService") - service = null - serviceBound = false - } - } - - /** - * Binds to the [SessionLifecycleService] and passes a callback [Messenger] that will be used to - * relay session updates to this client. - */ - fun bindToService(sessionLifecycleServiceBinder: SessionLifecycleServiceBinder) { - sessionLifecycleServiceBinder.bindToService( - Messenger(ClientUpdateHandler(backgroundDispatcher)), - serviceConnection, - ) - } - - /** - * Should be called when any activity in this application process goes to the foreground. This - * will relay the event to the [SessionLifecycleService] where it can make the determination of - * whether or not this foregrounding event should result in a new session being generated. - */ - fun foregrounded() { - sendLifecycleEvent(SessionLifecycleService.FOREGROUNDED) - } - - /** - * Should be called when any activity in this application process goes from the foreground to the - * background. This will relay the event to the [SessionLifecycleService] where it will be used to - * determine when a new session should be generated. - */ - fun backgrounded() { - sendLifecycleEvent(SessionLifecycleService.BACKGROUNDED) - } - - /** - * Sends a message to the [SessionLifecycleService] with the given event code. This will - * potentially also send any messages that have been queued up but not successfully delivered to - * this service since the previous send. - */ - private fun sendLifecycleEvent(messageCode: Int) { - val allMessages = drainQueue() - allMessages.add(Message.obtain(null, messageCode, 0, 0)) - sendLifecycleEvents(allMessages) - } - - /** - * Sends lifecycle events to the [SessionLifecycleService]. This will only send the latest - * FOREGROUND and BACKGROUND events to the service that are included in the given list. Running - * through the full backlog of messages is not useful since the service only cares about the - * current state and transitions from background -> foreground. - * - * Does not send events unless data collection is enabled for at least one subscriber. - */ - @CanIgnoreReturnValue - private fun sendLifecycleEvents(messages: List) = - CoroutineScope(backgroundDispatcher).launch { - val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() - if (subscribers.isEmpty()) { - Log.d( - TAG, - "Sessions SDK did not have any dependent SDKs register as dependencies. Events will not be sent.", - ) - } else if (subscribers.values.none { it.isDataCollectionEnabled }) { - Log.d(TAG, "Data Collection is disabled for all subscribers. Skipping this Event") - } else { - mutableListOf( - getLatestByCode(messages, SessionLifecycleService.BACKGROUNDED), - getLatestByCode(messages, SessionLifecycleService.FOREGROUNDED), - ) - .filterNotNull() - .sortedBy { it.getWhen() } - .forEach { sendMessageToServer(it) } - } - } - - /** Sends the given [Message] to the [SessionLifecycleService]. */ - private fun sendMessageToServer(msg: Message) { - if (service != null) { - try { - Log.d(TAG, "Sending lifecycle ${msg.what} to service") - service?.send(msg) - } catch (e: RemoteException) { - Log.w(TAG, "Unable to deliver message: ${msg.what}", e) - queueMessage(msg) - } - } else { - queueMessage(msg) - } - } - - /** - * Queues the given [Message] up for delivery to the [SessionLifecycleService] once the connection - * is established. - */ - private fun queueMessage(msg: Message) { - if (queuedMessages.offer(msg)) { - Log.d(TAG, "Queued message ${msg.what}. Queue size ${queuedMessages.size}") - } else { - Log.d(TAG, "Failed to enqueue message ${msg.what}. Dropping.") - } - } - - /** Drains the queue of messages into a new list in a thread-safe manner. */ - private fun drainQueue(): MutableList { - val messages = mutableListOf() - queuedMessages.drainTo(messages) - return messages - } - - /** Gets the message in the given list with the given code that has the latest timestamp. */ - private fun getLatestByCode(messages: List, msgCode: Int): Message? = - messages.filter { it.what == msgCode }.maxByOrNull { it.getWhen() } - - companion object { - const val TAG = "SessionLifecycleClient" - - /** - * The maximum number of messages that we should queue up for delivery to the - * [SessionLifecycleService] in the event that we have lost the connection. - */ - private const val MAX_QUEUED_MESSAGES = 20 - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt deleted file mode 100644 index 85930dc5455..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.app.Service -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.DeadObjectException -import android.os.Handler -import android.os.HandlerThread -import android.os.IBinder -import android.os.Looper -import android.os.Message -import android.os.Messenger -import android.util.Log -import com.google.firebase.sessions.settings.SessionsSettings - -/** - * Service for monitoring application lifecycle events and determining when/if a new session should - * be generated. When this happens, the service will broadcast the updated session id to all - * connected clients. - */ -internal class SessionLifecycleService : Service() { - - /** The thread that will be used to process all lifecycle messages from connected clients. */ - internal val handlerThread: HandlerThread = HandlerThread("FirebaseSessions_HandlerThread") - - /** The handler that will process all lifecycle messages from connected clients . */ - private var messageHandler: MessageHandler? = null - - /** The single messenger that will be sent to all connected clients of this service . */ - private var messenger: Messenger? = null - - /** - * Handler of incoming activity lifecycle events being received from [SessionLifecycleClient]s. - * All incoming communication from connected clients comes through this class and will be used to - * determine when new sessions should be created. - */ - internal class MessageHandler(looper: Looper) : Handler(looper) { - - /** - * Flag indicating whether or not the app has ever come into the foreground during the lifetime - * of the service. If it has not, we can infer that the first foreground event is a cold-start - * - * Note: this is made volatile because we attempt to send the current session ID to newly bound - * clients, and this binding happens - */ - private var hasForegrounded: Boolean = false - - /** - * The timestamp of the last activity lifecycle message we've received from a client. Used to - * determine when the app has been idle for long enough to require a new session. - */ - private var lastMsgTimeMs: Long = 0 - - /** Queue of connected clients. */ - private val boundClients = ArrayList() - - override fun handleMessage(msg: Message) { - if (lastMsgTimeMs > msg.getWhen()) { - Log.d(TAG, "Ignoring old message from ${msg.getWhen()} which is older than $lastMsgTimeMs.") - return - } - when (msg.what) { - FOREGROUNDED -> handleForegrounding(msg) - BACKGROUNDED -> handleBackgrounding(msg) - CLIENT_BOUND -> handleClientBound(msg) - else -> { - Log.w(TAG, "Received unexpected event from the SessionLifecycleClient: $msg") - super.handleMessage(msg) - } - } - } - - /** - * Handles a foregrounding event by any activity owned by the application as specified by the - * given [Message]. This will determine if the foregrounding should result in the creation of a - * new session. - */ - private fun handleForegrounding(msg: Message) { - Log.d(TAG, "Activity foregrounding at ${msg.getWhen()}.") - if (!hasForegrounded) { - Log.d(TAG, "Cold start detected.") - hasForegrounded = true - newSession() - } else if (isSessionRestart(msg.getWhen())) { - Log.d(TAG, "Session too long in background. Creating new session.") - newSession() - } - lastMsgTimeMs = msg.getWhen() - } - - /** - * Handles a backgrounding event by any activity owned by the application as specified by the - * given [Message]. This will keep track of the backgrounding and be used to determine if future - * foregrounding events should result in the creation of a new session. - */ - private fun handleBackgrounding(msg: Message) { - Log.d(TAG, "Activity backgrounding at ${msg.getWhen()}") - lastMsgTimeMs = msg.getWhen() - } - - /** - * Handles a newly bound client to this service by adding it to the list of callback clients and - * attempting to send it the latest session id immediately. - */ - private fun handleClientBound(msg: Message) { - boundClients.add(msg.replyTo) - maybeSendSessionToClient(msg.replyTo) - Log.d(TAG, "Client ${msg.replyTo} bound at ${msg.getWhen()}. Clients: ${boundClients.size}") - } - - /** Generates a new session id and sends it everywhere it's needed */ - private fun newSession() { - try { - SessionGenerator.instance.generateNewSession() - Log.d(TAG, "Generated new session.") - broadcastSession() - SessionDatastore.instance.updateSessionId( - SessionGenerator.instance.currentSession.sessionId - ) - } catch (ex: IllegalStateException) { - Log.w(TAG, "Failed to generate new session.", ex) - } - } - - /** - * Broadcasts the current session to by uploading to Firelog and all sending a message to all - * connected clients. - */ - private fun broadcastSession() { - Log.d(TAG, "Broadcasting new session") - SessionFirelogPublisher.instance.logSession(SessionGenerator.instance.currentSession) - // Create a defensive copy because DeadObjectExceptions on send will modify boundClients - val clientsToSend = ArrayList(boundClients) - clientsToSend.forEach { maybeSendSessionToClient(it) } - } - - private fun maybeSendSessionToClient(client: Messenger) { - try { - if (hasForegrounded) { - sendSessionToClient(client, SessionGenerator.instance.currentSession.sessionId) - } else { - // Send the value from the datastore before the first foregrounding it exists - val storedSession = SessionDatastore.instance.getCurrentSessionId() - Log.d(TAG, "App has not yet foregrounded. Using previously stored session.") - storedSession?.let { sendSessionToClient(client, it) } - } - } catch (ex: IllegalStateException) { - Log.w(TAG, "Failed to send session to client.", ex) - } - } - - /** Sends the current session id to the client connected through the given [Messenger]. */ - private fun sendSessionToClient(client: Messenger, sessionId: String) { - try { - val msgData = Bundle().also { it.putString(SESSION_UPDATE_EXTRA, sessionId) } - client.send(Message.obtain(null, SESSION_UPDATED, 0, 0).also { it.data = msgData }) - } catch (e: DeadObjectException) { - Log.d(TAG, "Removing dead client from list: $client") - boundClients.remove(client) - } catch (e: Exception) { - Log.w(TAG, "Unable to push new session to $client.", e) - } - } - - /** - * Determines if the foregrounding that occurred at the given time should trigger a new session - * because the app has been idle for too long. - */ - private fun isSessionRestart(foregroundTimeMs: Long) = - (foregroundTimeMs - lastMsgTimeMs) > - SessionsSettings.instance.sessionRestartTimeout.inWholeMilliseconds - } - - override fun onCreate() { - super.onCreate() - handlerThread.start() - messageHandler = MessageHandler(handlerThread.looper) - messenger = Messenger(messageHandler) - Log.d(TAG, "Service created on process ${android.os.Process.myPid()}") - } - - /** Called when a new [SessionLifecycleClient] binds to this service. */ - override fun onBind(intent: Intent?): IBinder? = - if (intent == null) { - Log.d(TAG, "Service bound with null intent. Ignoring.") - null - } else { - Log.d(TAG, "Service bound to new client on process ${intent.action}") - val callbackMessenger = getClientCallback(intent) - if (callbackMessenger != null) { - val clientBoundMsg = Message.obtain(null, CLIENT_BOUND, 0, 0) - clientBoundMsg.replyTo = callbackMessenger - messageHandler?.sendMessage(clientBoundMsg) - } - messenger?.binder - } - - override fun onDestroy() { - super.onDestroy() - handlerThread.quit() - } - - /** - * Extracts the callback [Messenger] from the given [Intent] which will be used to push session - * updates back to the [SessionLifecycleClient] that created this [Intent]. - */ - private fun getClientCallback(intent: Intent): Messenger? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(CLIENT_CALLBACK_MESSENGER, Messenger::class.java) - } else { - @Suppress("DEPRECATION") intent.getParcelableExtra(CLIENT_CALLBACK_MESSENGER) - } - - internal companion object { - const val TAG = "SessionLifecycleService" - - /** - * Key for the [Messenger] callback extra included in the [Intent] used by the - * [SessionLifecycleClient] to bind to this service. - */ - const val CLIENT_CALLBACK_MESSENGER = "ClientCallbackMessenger" - - /** - * Key for the extra String included in the [SESSION_UPDATED] message, sent to all connected - * clients, containing an updated session id. - */ - const val SESSION_UPDATE_EXTRA = "SessionUpdateExtra" - - /** [Message] code indicating that an application activity has gone to the foreground */ - const val FOREGROUNDED = 1 - /** [Message] code indicating that an application activity has gone to the background */ - const val BACKGROUNDED = 2 - /** - * [Message] code indicating that a new session has been started, and containing the new session - * id in the [SESSION_UPDATE_EXTRA] extra field. - */ - const val SESSION_UPDATED = 3 - - /** - * [Message] code indicating that a new client has been bound to the service. The - * [Message.replyTo] field will contain the new client callback interface. - */ - private const val CLIENT_BOUND = 4 - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt deleted file mode 100644 index 094a76ee51c..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Messenger -import android.util.Log -import javax.inject.Inject -import javax.inject.Singleton - -/** Interface for binding with the [SessionLifecycleService]. */ -internal fun interface SessionLifecycleServiceBinder { - /** - * Binds the given client callback [Messenger] to the [SessionLifecycleService]. The given - * callback will be used to relay session updates to this client. - */ - fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) -} - -@Singleton -internal class SessionLifecycleServiceBinderImpl -@Inject -constructor(private val appContext: Context) : SessionLifecycleServiceBinder { - - override fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) { - Intent(appContext, SessionLifecycleService::class.java).also { intent -> - Log.d(TAG, "Binding service to application.") - // This is necessary for the onBind() to be called by each process - intent.action = android.os.Process.myPid().toString() - intent.putExtra(SessionLifecycleService.CLIENT_CALLBACK_MESSENGER, callback) - intent.setPackage(appContext.packageName) - - val isServiceBound = - try { - appContext.bindService( - intent, - serviceConnection, - Context.BIND_IMPORTANT or Context.BIND_AUTO_CREATE, - ) - } catch (ex: SecurityException) { - Log.w(TAG, "Failed to bind session lifecycle service to application.", ex) - false - } - if (!isServiceBound) { - unbindServiceSafely(appContext, serviceConnection) - Log.i(TAG, "Session lifecycle service binding failed.") - } - } - } - - private fun unbindServiceSafely(appContext: Context, serviceConnection: ServiceConnection) = - try { - appContext.unbindService(serviceConnection) - } catch (ex: IllegalArgumentException) { - Log.w(TAG, "Session lifecycle service binding failed.", ex) - } - - private companion object { - const val TAG = "LifecycleServiceBinder" - } -} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt index b72c1da5cf3..9d6f37b05e6 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt @@ -19,33 +19,36 @@ package com.google.firebase.sessions import android.app.Activity import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle -import androidx.annotation.VisibleForTesting +import javax.inject.Inject +import javax.inject.Singleton /** - * Lifecycle callbacks that will inform the [SessionLifecycleClient] whenever an [Activity] in this + * Lifecycle callbacks that will inform the [SharedSessionRepository] whenever an [Activity] in this * application process goes foreground or background. */ -internal object SessionsActivityLifecycleCallbacks : ActivityLifecycleCallbacks { - @VisibleForTesting internal var hasPendingForeground: Boolean = false - - var lifecycleClient: SessionLifecycleClient? = null - /** Sets the client and calls [SessionLifecycleClient.foregrounded] for pending foreground. */ - set(lifecycleClient) { - field = lifecycleClient - lifecycleClient?.let { - if (hasPendingForeground) { - hasPendingForeground = false - it.foregrounded() - } - } - } +@Singleton +internal class SessionsActivityLifecycleCallbacks +@Inject +constructor(private val sharedSessionRepository: SharedSessionRepository) : + ActivityLifecycleCallbacks { + private var enabled = true + + fun onAppDelete() { + enabled = false + } override fun onActivityResumed(activity: Activity) { - lifecycleClient?.foregrounded() ?: run { hasPendingForeground = true } + // There is a known issue in API level 34 where in some cases with a split screen, the call to + // onActivityResumed can happen before the call to onActivityPaused. This is fixed in API 35+ + if (enabled) { + sharedSessionRepository.appForeground() + } } override fun onActivityPaused(activity: Activity) { - lifecycleClient?.backgrounded() + if (enabled) { + sharedSessionRepository.appBackground() + } } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt new file mode 100644 index 00000000000..7534ba8b1e8 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import android.util.Log +import androidx.datastore.core.DataStore +import com.google.firebase.Firebase +import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.app +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG +import com.google.firebase.sessions.api.FirebaseSessionsDependencies +import com.google.firebase.sessions.api.SessionSubscriber +import com.google.firebase.sessions.settings.SessionsSettings +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch + +/** Repository to persist session data to be shared between all app processes. */ +internal interface SharedSessionRepository { + val isInForeground: Boolean + + fun appBackground() + + fun appForeground() + + companion object { + val instance: SharedSessionRepository + get() = Firebase.app[FirebaseSessionsComponent::class.java].sharedSessionRepository + } +} + +@Singleton +internal class SharedSessionRepositoryImpl +@Inject +constructor( + private val sessionsSettings: SessionsSettings, + private val sessionGenerator: SessionGenerator, + private val sessionFirelogPublisher: SessionFirelogPublisher, + private val timeProvider: TimeProvider, + private val sessionDataStore: DataStore, + private val processDataManager: ProcessDataManager, + @Background private val backgroundDispatcher: CoroutineContext, +) : SharedSessionRepository { + /** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */ + internal lateinit var localSessionData: SessionData + + override var isInForeground = false + private set + + /** + * Either notify the subscribers with general multi-process supported session or fallback local + * session + */ + internal enum class NotificationType { + GENERAL, + FALLBACK, + } + + internal var previousNotificationType: NotificationType = NotificationType.GENERAL + private var previousSessionId: String = "" + + init { + CoroutineScope(backgroundDispatcher).launch { + sessionDataStore.data + .catch { + val newSession = + SessionData( + sessionDetails = sessionGenerator.generateNewSession(null), + backgroundTime = null, + ) + Log.d( + TAG, + "Init session datastore failed with exception message: ${it.message}. Emit fallback session ${newSession.sessionDetails.sessionId}", + ) + emit(newSession) + } + .collect { sessionData -> + localSessionData = sessionData + val sessionId = sessionData.sessionDetails.sessionId + notifySubscribers(sessionId, NotificationType.GENERAL) + } + } + } + + override fun appBackground() { + isInForeground = false + if (!::localSessionData.isInitialized) { + Log.d(TAG, "App backgrounded, but local SessionData not initialized") + return + } + Log.d(TAG, "App backgrounded on ${processDataManager.myProcessName}") + + CoroutineScope(backgroundDispatcher).launch { + try { + sessionDataStore.updateData { sessionData -> + sessionData.copy(backgroundTime = timeProvider.currentTime()) + } + } catch (ex: Exception) { + Log.d(TAG, "App backgrounded, failed to update data. Message: ${ex.message}") + localSessionData = localSessionData.copy(backgroundTime = timeProvider.currentTime()) + } + } + } + + override fun appForeground() { + isInForeground = true + if (!::localSessionData.isInitialized) { + Log.d(TAG, "App foregrounded, but local SessionData not initialized") + return + } + val sessionData = localSessionData + Log.d(TAG, "App foregrounded on ${processDataManager.myProcessName}") + + // Check if maybe the session data needs to be updated + if (isSessionExpired(sessionData) || isMyProcessStale(sessionData)) { + CoroutineScope(backgroundDispatcher).launch { + try { + sessionDataStore.updateData { currentSessionData -> + // Check again using the current session data on disk + val isSessionExpired = isSessionExpired(currentSessionData) + val isColdStart = isColdStart(currentSessionData) + val isMyProcessStale = isMyProcessStale(currentSessionData) + + val newProcessDataMap = + if (isColdStart) { + // Generate a new process data map for cold app start + processDataManager.generateProcessDataMap() + } else if (isMyProcessStale) { + // Update the data map with this process if stale + processDataManager.updateProcessDataMap(currentSessionData.processDataMap) + } else { + // No change + currentSessionData.processDataMap + } + + val currentSession = + if (isColdStart) { + // For a cold start, do not keep the current session + null + } else { + currentSessionData.sessionDetails + } + + // This is an expression, and returns the updated session data + if (isSessionExpired || isColdStart) { + val newSessionDetails = sessionGenerator.generateNewSession(currentSession) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + processDataManager.onSessionGenerated() + currentSessionData.copy( + sessionDetails = newSessionDetails, + backgroundTime = null, + processDataMap = newProcessDataMap, + ) + } else if (isMyProcessStale) { + currentSessionData.copy( + processDataMap = processDataManager.updateProcessDataMap(newProcessDataMap) + ) + } else { + currentSessionData + } + } + } catch (ex: Exception) { + Log.d(TAG, "App foregrounded, failed to update data. Message: ${ex.message}") + if (isSessionExpired(sessionData)) { + val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) + localSessionData = + sessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + notifySubscribers(newSessionDetails.sessionId, NotificationType.FALLBACK) + } + } + } + } + } + + private suspend fun notifySubscribers(sessionId: String, type: NotificationType) { + previousNotificationType = type + if (previousSessionId == sessionId) { + return + } + previousSessionId = sessionId + FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> + // Notify subscribers, regardless of sampling and data collection state + subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) + Log.d( + TAG, + when (type) { + NotificationType.GENERAL -> + "Notified ${subscriber.sessionSubscriberName} of new session $sessionId" + NotificationType.FALLBACK -> + "Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId" + }, + ) + } + } + + /** Checks if the session has expired. If no background time, consider it not expired. */ + private fun isSessionExpired(sessionData: SessionData): Boolean { + sessionData.backgroundTime?.let { backgroundTime -> + val interval = timeProvider.currentTime() - backgroundTime + val sessionExpired = (interval > sessionsSettings.sessionRestartTimeout) + if (sessionExpired) { + Log.d(TAG, "Session ${sessionData.sessionDetails.sessionId} is expired") + } + return sessionExpired + } + + Log.d(TAG, "Session ${sessionData.sessionDetails.sessionId} has not backgrounded yet") + return false + } + + /** Checks for cold app start. If no process data map, consider it a cold start. */ + private fun isColdStart(sessionData: SessionData): Boolean { + sessionData.processDataMap?.let { processDataMap -> + val coldStart = processDataManager.isColdStart(processDataMap) + if (coldStart) { + Log.d(TAG, "Cold app start detected") + } + return coldStart + } + + Log.d(TAG, "No process data map") + return true + } + + /** Checks if this process is stale. If no process data map, consider the process stale. */ + private fun isMyProcessStale(sessionData: SessionData): Boolean { + sessionData.processDataMap?.let { processDataMap -> + val myProcessStale = processDataManager.isMyProcessStale(processDataMap) + if (myProcessStale) { + Log.d(TAG, "Process ${processDataManager.myProcessName} is stale") + } + return myProcessStale + } + + Log.d(TAG, "No process data for ${processDataManager.myProcessName}") + return true + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt index b66b09af19f..933decdd3e1 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/TimeProvider.kt @@ -19,12 +19,22 @@ package com.google.firebase.sessions import android.os.SystemClock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlinx.serialization.Serializable + +/** Time with accessors for microseconds, milliseconds, and seconds. */ +@Serializable +internal data class Time(val ms: Long) { + val us = ms * 1_000 + val seconds = ms / 1_000 + + operator fun minus(time: Time): Duration = (ms - time.ms).milliseconds +} /** Time provider interface, for testing purposes. */ internal interface TimeProvider { fun elapsedRealtime(): Duration - fun currentTimeUs(): Long + fun currentTime(): Time } /** "Wall clock" time provider implementation. */ @@ -38,14 +48,11 @@ internal object TimeProviderImpl : TimeProvider { override fun elapsedRealtime(): Duration = SystemClock.elapsedRealtime().milliseconds /** - * Gets the current "wall clock" time in microseconds. + * Gets the current "wall clock" time. * * This clock can be set by the user or the phone network, so the time may jump backwards or * forwards unpredictably. This clock should only be used when correspondence with real-world * dates and times is important, such as in a calendar or alarm clock application. */ - override fun currentTimeUs(): Long = System.currentTimeMillis() * US_PER_MILLIS - - /** Microseconds per millisecond. */ - private const val US_PER_MILLIS = 1000L + override fun currentTime(): Time = Time(ms = System.currentTimeMillis()) } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt index 2c40e96812e..9318ed944e0 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/CrashEventReceiver.kt @@ -16,6 +16,9 @@ package com.google.firebase.sessions.api +import androidx.annotation.VisibleForTesting +import com.google.firebase.sessions.SharedSessionRepository + /** * Internal API used by Firebase Crashlytics to notify the Firebase Sessions SDK of fatal crashes. * @@ -23,6 +26,8 @@ package com.google.firebase.sessions.api * crash has occurred. */ object CrashEventReceiver { + @VisibleForTesting internal lateinit var sharedSessionRepository: SharedSessionRepository + /** * Notifies the Firebase Sessions SDK that a fatal crash has occurred. * @@ -34,6 +39,18 @@ object CrashEventReceiver { */ @JvmStatic fun notifyCrashOccurred() { - // TODO(mrober): Implement in #7039 + try { + if (!::sharedSessionRepository.isInitialized) { + sharedSessionRepository = SharedSessionRepository.instance + } + // Treat a foreground crash as if the app went to the background, and update session state. + if (sharedSessionRepository.isInForeground) { + sharedSessionRepository.appBackground() + } + } catch (_: Exception) { + // Catch and suppress any exception to avoid crashing during crash handling. + // This can occur if Firebase or the SDK are in an unexpected state (e.g. FirebaseApp deleted) + // No action needed, avoid interfering with the crash reporting process. + } } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt index 8d3548c8f4b..adf3ce8c950 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependencies.kt @@ -18,6 +18,7 @@ package com.google.firebase.sessions.api import android.util.Log import androidx.annotation.VisibleForTesting +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import java.util.Collections.synchronizedMap import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -30,8 +31,6 @@ import kotlinx.coroutines.sync.withLock * This is important because the Sessions SDK starts up before dependent SDKs. */ object FirebaseSessionsDependencies { - private const val TAG = "SessionsDependencies" - private val dependencies = synchronizedMap(mutableMapOf()) /** @@ -40,19 +39,6 @@ object FirebaseSessionsDependencies { */ @JvmStatic fun addDependency(subscriberName: SessionSubscriber.Name) { - if (subscriberName == SessionSubscriber.Name.PERFORMANCE) { - throw IllegalArgumentException( - """ - Incompatible versions of Firebase Perf and Firebase Sessions. - A safe combination would be: - firebase-sessions:1.1.0 - firebase-crashlytics:18.5.0 - firebase-perf:20.5.0 - For more information contact Firebase Support. - """ - .trimIndent() - ) - } if (dependencies.containsKey(subscriberName)) { Log.d(TAG, "Dependency $subscriberName already added.") return diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt index 67a48bc7924..1b25202b4f9 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt @@ -19,18 +19,16 @@ package com.google.firebase.sessions.settings import android.os.Build import android.util.Log import androidx.annotation.VisibleForTesting -import com.google.firebase.annotations.concurrent.Background import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.sessions.ApplicationInfo +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.InstallationId -import dagger.Lazy +import com.google.firebase.sessions.TimeProvider import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.CoroutineContext import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONException @@ -40,15 +38,12 @@ import org.json.JSONObject internal class RemoteSettings @Inject constructor( - @Background private val backgroundDispatcher: CoroutineContext, + private val timeProvider: TimeProvider, private val firebaseInstallationsApi: FirebaseInstallationsApi, private val appInfo: ApplicationInfo, private val configsFetcher: CrashlyticsSettingsFetcher, - private val lazySettingsCache: Lazy, + private val settingsCache: SettingsCache, ) : SettingsProvider { - private val settingsCache: SettingsCache - get() = lazySettingsCache.get() - private val fetchInProgress = Mutex() override val sessionEnabled: Boolean? @@ -90,10 +85,9 @@ constructor( val options = mapOf( "X-Crashlytics-Installation-ID" to installationId, - "X-Crashlytics-Device-Model" to - removeForwardSlashesIn(String.format("%s/%s", Build.MANUFACTURER, Build.MODEL)), - "X-Crashlytics-OS-Build-Version" to removeForwardSlashesIn(Build.VERSION.INCREMENTAL), - "X-Crashlytics-OS-Display-Version" to removeForwardSlashesIn(Build.VERSION.RELEASE), + "X-Crashlytics-Device-Model" to sanitize("${Build.MANUFACTURER}${Build.MODEL}"), + "X-Crashlytics-OS-Build-Version" to sanitize(Build.VERSION.INCREMENTAL), + "X-Crashlytics-OS-Display-Version" to sanitize(Build.VERSION.RELEASE), "X-Crashlytics-API-Client-Version" to appInfo.sessionSdkVersion, ) @@ -129,22 +123,19 @@ constructor( } } - sessionsEnabled?.let { settingsCache.updateSettingsEnabled(sessionsEnabled) } - - sessionTimeoutSeconds?.let { - settingsCache.updateSessionRestartTimeout(sessionTimeoutSeconds) - } - - sessionSamplingRate?.let { settingsCache.updateSamplingRate(sessionSamplingRate) } - - cacheDuration?.let { settingsCache.updateSessionCacheDuration(cacheDuration) } - ?: let { settingsCache.updateSessionCacheDuration(86400) } - - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = sessionsEnabled, + sessionTimeoutSeconds = sessionTimeoutSeconds, + sessionSamplingRate = sessionSamplingRate, + cacheDurationSeconds = cacheDuration ?: defaultCacheDuration, + cacheUpdatedTimeSeconds = timeProvider.currentTime().seconds, + ) + ) }, onFailure = { msg -> // Network request failed here. - Log.e(TAG, "Error failing to fetch the remote configs: $msg") + Log.e(TAG, "Error failed to fetch the remote configs: $msg") }, ) } @@ -153,18 +144,15 @@ constructor( override fun isSettingsStale(): Boolean = settingsCache.hasCacheExpired() @VisibleForTesting - internal fun clearCachedSettings() { - val scope = CoroutineScope(backgroundDispatcher) - scope.launch { settingsCache.removeConfigs() } + internal suspend fun clearCachedSettings() { + settingsCache.updateConfigs(SessionConfigsSerializer.defaultValue) } - private fun removeForwardSlashesIn(s: String): String { - return s.replace(FORWARD_SLASH_STRING.toRegex(), "") - } + private fun sanitize(s: String) = s.replace(sanitizeRegex, "") private companion object { - const val TAG = "SessionConfigFetcher" + val defaultCacheDuration = 24.hours.inWholeSeconds.toInt() - const val FORWARD_SLASH_STRING: String = "/" + val sanitizeRegex = "/".toRegex() } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt index 92d530f2fa1..bd45ec8fb24 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettingsFetcher.kt @@ -17,7 +17,7 @@ package com.google.firebase.sessions.settings import android.net.Uri -import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.annotations.concurrent.Blocking import com.google.firebase.sessions.ApplicationInfo import java.io.BufferedReader import java.io.InputStreamReader @@ -42,7 +42,7 @@ internal class RemoteSettingsFetcher @Inject constructor( private val appInfo: ApplicationInfo, - @Background private val blockingDispatcher: CoroutineContext, + @Blocking private val blockingDispatcher: CoroutineContext, ) : CrashlyticsSettingsFetcher { @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls. override suspend fun doConfigFetch( diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt new file mode 100644 index 00000000000..ab310ebed8a --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.settings + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Session configs data for caching. */ +@Serializable +internal data class SessionConfigs( + val sessionsEnabled: Boolean?, + val sessionSamplingRate: Double?, + val sessionTimeoutSeconds: Int?, + val cacheDurationSeconds: Int?, + val cacheUpdatedTimeSeconds: Long?, +) + +/** DataStore json [Serializer] for [SessionConfigs]. */ +internal object SessionConfigsSerializer : Serializer { + override val defaultValue = + SessionConfigs( + sessionsEnabled = null, + sessionSamplingRate = null, + sessionTimeoutSeconds = null, + cacheDurationSeconds = null, + cacheUpdatedTimeSeconds = null, + ) + + override suspend fun readFrom(input: InputStream): SessionConfigs = + try { + Json.decodeFromString(input.readBytes().decodeToString()) + } catch (ex: Exception) { + throw CorruptionException("Cannot parse session configs", ex) + } + + override suspend fun writeTo(t: SessionConfigs, output: OutputStream) { + @Suppress("BlockingMethodInNonBlockingContext") // blockingDispatcher is safe for blocking calls + output.write(Json.encodeToString(SessionConfigs.serializer(), t).encodeToByteArray()) + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt index 2e60e51650a..6b5cc96a138 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SettingsCache.kt @@ -19,126 +19,93 @@ package com.google.firebase.sessions.settings import android.util.Log import androidx.annotation.VisibleForTesting import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.doublePreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import com.google.firebase.sessions.SessionConfigsDataStore +import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.sessions.FirebaseSessions.Companion.TAG +import com.google.firebase.sessions.TimeProvider import java.io.IOException +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -internal data class SessionConfigs( - val sessionEnabled: Boolean?, - val sessionSamplingRate: Double?, - val sessionRestartTimeout: Int?, - val cacheDuration: Int?, - val cacheUpdatedTime: Long?, -) +internal interface SettingsCache { + fun hasCacheExpired(): Boolean + + fun sessionsEnabled(): Boolean? + + fun sessionSamplingRate(): Double? + + fun sessionRestartTimeout(): Int? + + suspend fun updateConfigs(sessionConfigs: SessionConfigs) +} @Singleton -internal class SettingsCache +internal class SettingsCacheImpl @Inject -constructor(@SessionConfigsDataStore private val dataStore: DataStore) { - private lateinit var sessionConfigs: SessionConfigs +constructor( + @Background private val backgroundDispatcher: CoroutineContext, + private val timeProvider: TimeProvider, + private val sessionConfigsDataStore: DataStore, +) : SettingsCache { + private val sessionConfigsAtomicReference = AtomicReference() + + private val sessionConfigs: SessionConfigs + get() { + // Ensure configs are loaded from disk before the first access + if (sessionConfigsAtomicReference.get() == null) { + // Double check to avoid the `runBlocking` unless necessary + sessionConfigsAtomicReference.compareAndSet( + null, + runBlocking { sessionConfigsDataStore.data.first() }, + ) + } - init { - // Block until the cache is loaded from disk to ensure cache - // values are valid and readable from the main thread on init. - runBlocking { updateSessionConfigs(dataStore.data.first().toPreferences()) } - } + return sessionConfigsAtomicReference.get() + } - /** Update session configs from the given [preferences]. */ - private fun updateSessionConfigs(preferences: Preferences) { - sessionConfigs = - SessionConfigs( - sessionEnabled = preferences[SESSIONS_ENABLED], - sessionSamplingRate = preferences[SAMPLING_RATE], - sessionRestartTimeout = preferences[RESTART_TIMEOUT_SECONDS], - cacheDuration = preferences[CACHE_DURATION_SECONDS], - cacheUpdatedTime = preferences[CACHE_UPDATED_TIME], - ) + init { + CoroutineScope(backgroundDispatcher).launch { + sessionConfigsDataStore.data.collect(sessionConfigsAtomicReference::set) + } } - internal fun hasCacheExpired(): Boolean { - val cacheUpdatedTime = sessionConfigs.cacheUpdatedTime - val cacheDuration = sessionConfigs.cacheDuration + override fun hasCacheExpired(): Boolean { + val cacheUpdatedTimeSeconds = sessionConfigs.cacheUpdatedTimeSeconds + val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds - if (cacheUpdatedTime != null && cacheDuration != null) { - val timeDifferenceSeconds = (System.currentTimeMillis() - cacheUpdatedTime) / 1000 - if (timeDifferenceSeconds < cacheDuration) { + if (cacheUpdatedTimeSeconds != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = timeProvider.currentTime().seconds - cacheUpdatedTimeSeconds + if (timeDifferenceSeconds < cacheDurationSeconds) { return false } } return true } - fun sessionsEnabled(): Boolean? = sessionConfigs.sessionEnabled - - fun sessionSamplingRate(): Double? = sessionConfigs.sessionSamplingRate - - fun sessionRestartTimeout(): Int? = sessionConfigs.sessionRestartTimeout - - suspend fun updateSettingsEnabled(enabled: Boolean?) { - updateConfigValue(SESSIONS_ENABLED, enabled) - } - - suspend fun updateSamplingRate(rate: Double?) { - updateConfigValue(SAMPLING_RATE, rate) - } - - suspend fun updateSessionRestartTimeout(timeoutInSeconds: Int?) { - updateConfigValue(RESTART_TIMEOUT_SECONDS, timeoutInSeconds) - } + override fun sessionsEnabled(): Boolean? = sessionConfigs.sessionsEnabled - suspend fun updateSessionCacheDuration(cacheDurationInSeconds: Int?) { - updateConfigValue(CACHE_DURATION_SECONDS, cacheDurationInSeconds) - } + override fun sessionSamplingRate(): Double? = sessionConfigs.sessionSamplingRate - suspend fun updateSessionCacheUpdatedTime(cacheUpdatedTime: Long?) { - updateConfigValue(CACHE_UPDATED_TIME, cacheUpdatedTime) - } + override fun sessionRestartTimeout(): Int? = sessionConfigs.sessionTimeoutSeconds - @VisibleForTesting - internal suspend fun removeConfigs() { + override suspend fun updateConfigs(sessionConfigs: SessionConfigs) { try { - dataStore.edit { preferences -> - preferences.clear() - updateSessionConfigs(preferences) - } - } catch (e: IOException) { - Log.w(TAG, "Failed to remove config values: $e") + sessionConfigsDataStore.updateData { sessionConfigs } + } catch (ex: IOException) { + Log.w(TAG, "Failed to update config values: $ex") } } - /** Updated the config value, or remove the key if the value is null. */ - private suspend fun updateConfigValue(key: Preferences.Key, value: T?) { - // TODO(mrober): Refactor these to update all the values in one transaction. + @VisibleForTesting + internal suspend fun removeConfigs() = try { - dataStore.edit { preferences -> - if (value != null) { - preferences[key] = value - } else { - preferences.remove(key) - } - updateSessionConfigs(preferences) - } + sessionConfigsDataStore.updateData { SessionConfigsSerializer.defaultValue } } catch (ex: IOException) { - Log.w(TAG, "Failed to update cache config value: $ex") + Log.w(TAG, "Failed to remove config values: $ex") } - } - - private companion object { - const val TAG = "SettingsCache" - - val SESSIONS_ENABLED = booleanPreferencesKey("firebase_sessions_enabled") - val SAMPLING_RATE = doublePreferencesKey("firebase_sessions_sampling_rate") - val RESTART_TIMEOUT_SECONDS = intPreferencesKey("firebase_sessions_restart_timeout") - val CACHE_DURATION_SECONDS = intPreferencesKey("firebase_sessions_cache_duration") - val CACHE_UPDATED_TIME = longPreferencesKey("firebase_sessions_cache_updated_time") - } } diff --git a/firebase-sessions/src/test/AndroidManifest.xml b/firebase-sessions/src/test/AndroidManifest.xml deleted file mode 100644 index 4eccb7649da..00000000000 --- a/firebase-sessions/src/test/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt index 3c6ccf8644d..b026b7f33bc 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ApplicationInfoTest.kt @@ -36,9 +36,9 @@ class ApplicationInfoTest { @Test fun applicationInfo_populatesInfoCorrectly() { val firebaseApp = FakeFirebaseApp().firebaseApp - val actualCurrentProcessDetails = - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext) - val actualAppProcessDetails = + val myProcessDetails = + ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext) + val appProcessDetails = ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext) val applicationInfo = SessionEvents.getApplicationInfo(firebaseApp) assertThat(applicationInfo) @@ -54,15 +54,15 @@ class ApplicationInfoTest { versionName = FakeFirebaseApp.MOCK_APP_VERSION, appBuildVersion = FakeFirebaseApp.MOCK_APP_BUILD_VERSION, deviceManufacturer = Build.MANUFACTURER, - actualCurrentProcessDetails, - actualAppProcessDetails, - ) + myProcessDetails, + appProcessDetails, + ), ) ) } @Test - fun applicationInfo_missiongVersionCode_populatesInfoCorrectly() { + fun applicationInfo_missingVersionCode_populatesInfoCorrectly() { // Initialize Firebase with no version code set. val firebaseApp = Firebase.initialize( @@ -71,12 +71,12 @@ class ApplicationInfoTest { .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) .setApiKey(FakeFirebaseApp.MOCK_API_KEY) .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build() + .build(), ) - val actualCurrentProcessDetails = - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext) - val actualAppProcessDetails = + val myProcessDetails = + ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext) + val appProcessDetails = ProcessDetailsProvider.getAppProcessDetails(firebaseApp.applicationContext) val applicationInfo = SessionEvents.getApplicationInfo(firebaseApp) @@ -94,9 +94,9 @@ class ApplicationInfoTest { versionName = "0", appBuildVersion = "0", deviceManufacturer = Build.MANUFACTURER, - actualCurrentProcessDetails, - actualAppProcessDetails, - ) + myProcessDetails, + appProcessDetails, + ), ) ) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt index b636d53e3dc..fb3e58f44d6 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/EventGDTLoggerTest.kt @@ -27,14 +27,12 @@ import com.google.firebase.sessions.testing.FakeProvider import com.google.firebase.sessions.testing.FakeSettingsProvider import com.google.firebase.sessions.testing.FakeTransportFactory import com.google.firebase.sessions.testing.TestSessionEventData -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class EventGDTLoggerTest { diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt new file mode 100644 index 00000000000..12a5e8f128e --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.sessions.testing.FakeDataStore +import java.io.IOException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +/** Tests for the [FakeDataStore] implementation. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +internal class FakeDataStoreTest { + @Test + fun emitsProvidedValues() = runTest { + val fakeDataStore = FakeDataStore(23) + + val result = mutableListOf() + + // Collect data into result list + backgroundScope.launch { fakeDataStore.data.collect { result.add(it) } } + + fakeDataStore.updateData { 1 } + fakeDataStore.updateData { 2 } + fakeDataStore.updateData { 3 } + fakeDataStore.updateData { 4 } + + runCurrent() + + assertThat(result).containsExactly(23, 1, 2, 3, 4) + } + + @Test + fun throwsProvidedExceptionOnEmit() = runTest { + val fakeDataStore = FakeDataStore(23) + + val result = mutableListOf() + backgroundScope.launch { + fakeDataStore.data + .catch { ex -> result.add(ex.message!!) } + .collect { result.add(it.toString()) } + } + + fakeDataStore.updateData { 1 } + fakeDataStore.throwOnNextEmit(IOException("oops")) + + runCurrent() + + assertThat(result).containsExactly("23", "1", "oops") + } + + @Test(expected = IndexOutOfBoundsException::class) + fun throwsProvidedExceptionOnUpdateData() = runTest { + val fakeDataStore = FakeDataStore(23) + + fakeDataStore.updateData { 1 } + fakeDataStore.throwOnNextUpdateData(IndexOutOfBoundsException("oops")) + + // Expected to throw + fakeDataStore.updateData { 2 } + } + + @Test(expected = IllegalArgumentException::class) + fun throwsFirstProvidedExceptionOnCollect() = runTest { + val fakeDataStore = FakeDataStore(23, IllegalArgumentException("oops")) + + // Expected to throw + fakeDataStore.data.collect {} + } + + @Test(expected = IllegalStateException::class) + fun throwsFirstProvidedExceptionOnFirst() = runTest { + val fakeDataStore = FakeDataStore(23, IllegalStateException("oops")) + + // Expected to throw + fakeDataStore.data.first() + } + + @Test + fun consistentAfterManyUpdates() = runTest { + val fakeDataStore = FakeDataStore(0) + + var collectResult = 0 + backgroundScope.launch { fakeDataStore.data.collect { collectResult = it } } + + var updateResult = 0 + // 100 is bigger than the channel buffer size so this will cause suspending + repeat(100) { updateResult = fakeDataStore.updateData { it.inc() } } + + runCurrent() + + assertThat(collectResult).isEqualTo(100) + assertThat(updateResult).isEqualTo(100) + + fakeDataStore.close() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt new file mode 100644 index 00000000000..3eddd371a0f --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeRunningAppProcessInfo +import com.google.firebase.sessions.testing.FakeUuidGenerator +import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_1 as MY_UUID +import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2 as OTHER_UUID +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ProcessDataManagerTest { + @Test + fun isColdStart_myProcess() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID))) + + assertThat(coldStart).isFalse() + } + + @Test + fun isColdStart_emptyProcessDataMap() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = processDataManager.isColdStart(processDataMap = emptyMap()) + + assertThat(coldStart).isTrue() + } + + fun isColdStart_myProcessCurrent_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart( + mapOf( + MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), + ) + ) + + assertThat(coldStart).isFalse() + } + + @Test + fun isColdStart_staleProcessPid() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID))) + + assertThat(coldStart).isTrue() + } + + @Test + fun isColdStart_staleProcessUuid() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, OTHER_UUID))) + + assertThat(coldStart).isTrue() + } + + @Test + fun isColdStart_myProcessStale_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val coldStart = + processDataManager.isColdStart( + mapOf( + MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), + ) + ) + + assertThat(coldStart).isFalse() + } + + @Test + fun isMyProcessStale() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo)).firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val myProcessStale = + processDataManager.isMyProcessStale(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID))) + + assertThat(myProcessStale).isFalse() + } + + @Test + fun isMyProcessStale_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val myProcessStale = + processDataManager.isMyProcessStale( + mapOf( + MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), + ) + ) + + assertThat(myProcessStale).isTrue() + } + + @Test + fun isMyProcessStale_missingMyProcessData() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) + + val myProcessStale = + processDataManager.isMyProcessStale( + mapOf(OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID)) + ) + + assertThat(myProcessStale).isTrue() + } + + @After + fun cleanUp() { + FirebaseApp.clearInstancesForTest() + } + + private companion object { + const val MY_PROCESS_NAME = "com.google.firebase.sessions.test" + const val OTHER_PROCESS_NAME = "not.my.process" + + const val MY_PID = 0 + const val OTHER_PID = 4 + + val myProcessInfo = FakeRunningAppProcessInfo(pid = MY_PID, processName = MY_PROCESS_NAME) + + val otherProcessInfo = + FakeRunningAppProcessInfo(pid = OTHER_PID, processName = OTHER_PROCESS_NAME) + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt index b41b33e3361..2517157c7e2 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDetailsProviderTest.kt @@ -40,9 +40,8 @@ class ProcessDetailsProviderTest { } @Test - fun getCurrentProcessDetails() { - val processDetails = - ProcessDetailsProvider.getCurrentProcessDetails(firebaseApp.applicationContext) + fun getMyProcessDetails() { + val processDetails = ProcessDetailsProvider.getMyProcessDetails(firebaseApp.applicationContext) assertThat(processDetails) .isEqualTo(ProcessDetails("com.google.firebase.sessions.test", 0, 100, false)) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt index 6f919cf946b..70772e733cb 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventEncoderTest.kt @@ -27,13 +27,11 @@ import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeSessionSubscriber import com.google.firebase.sessions.testing.FakeSettingsProvider import com.google.firebase.sessions.testing.TestSessionEventData -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class SessionEventEncoderTest { diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt index 682371491df..16fe6547ca6 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionEventTest.kt @@ -27,14 +27,12 @@ import com.google.firebase.sessions.testing.TestSessionEventData.TEST_DATA_COLLE import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_DATA import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_DETAILS import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_EVENT -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SessionEventTest { @Test diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt index 01d6bba540b..76c6864f491 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionFirelogPublisherTest.kt @@ -75,7 +75,7 @@ class SessionFirelogPublisherTest { ) // Construct an event with no fid set. - publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) + publisher.mayLogSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() @@ -105,7 +105,7 @@ class SessionFirelogPublisherTest { ) // Construct an event with no fid set. - publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) + publisher.mayLogSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() @@ -134,7 +134,7 @@ class SessionFirelogPublisherTest { ) // Construct an event with no fid set. - publisher.logSession(TestSessionEventData.TEST_SESSION_DETAILS) + publisher.mayLogSession(TestSessionEventData.TEST_SESSION_DETAILS) runCurrent() diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt index 7126bae4dbf..9aab92b766a 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt @@ -16,12 +16,15 @@ package com.google.firebase.sessions +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.sessions.testing.FakeTimeProvider import com.google.firebase.sessions.testing.FakeUuidGenerator -import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP_US +import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class SessionGeneratorTest { private fun isValidSessionId(sessionId: String): Boolean { if (sessionId.length != 32) { @@ -36,47 +39,21 @@ class SessionGeneratorTest { return true } - // This test case isn't important behavior. Nothing should access - // currentSession before generateNewSession has been called. - @Test(expected = UninitializedPropertyAccessException::class) - fun currentSession_beforeGenerate_throwsUninitialized() { - val sessionGenerator = - SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - - sessionGenerator.currentSession - } - - @Test - fun hasGenerateSession_beforeGenerate_returnsFalse() { - val sessionGenerator = - SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - - assertThat(sessionGenerator.hasGenerateSession).isFalse() - } - - @Test - fun hasGenerateSession_afterGenerate_returnsTrue() { - val sessionGenerator = - SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - - sessionGenerator.generateNewSession() - - assertThat(sessionGenerator.hasGenerateSession).isTrue() - } - @Test fun generateNewSession_generatesValidSessionIds() { val sessionGenerator = SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = UuidGeneratorImpl) - sessionGenerator.generateNewSession() + val sessionDetails = sessionGenerator.generateNewSession(currentSession = null) - assertThat(isValidSessionId(sessionGenerator.currentSession.sessionId)).isTrue() - assertThat(isValidSessionId(sessionGenerator.currentSession.firstSessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.sessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.firstSessionId)).isTrue() // Validate several random session ids. + var currentSession = sessionDetails repeat(16) { - assertThat(isValidSessionId(sessionGenerator.generateNewSession().sessionId)).isTrue() + currentSession = sessionGenerator.generateNewSession(currentSession) + assertThat(isValidSessionId(currentSession.sessionId)).isTrue() } } @@ -85,18 +62,18 @@ class SessionGeneratorTest { val sessionGenerator = SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = FakeUuidGenerator()) - sessionGenerator.generateNewSession() + val sessionDetails = sessionGenerator.generateNewSession(currentSession = null) - assertThat(isValidSessionId(sessionGenerator.currentSession.sessionId)).isTrue() - assertThat(isValidSessionId(sessionGenerator.currentSession.firstSessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.sessionId)).isTrue() + assertThat(isValidSessionId(sessionDetails.firstSessionId)).isTrue() - assertThat(sessionGenerator.currentSession) + assertThat(sessionDetails) .isEqualTo( SessionDetails( sessionId = SESSION_ID_1, firstSessionId = SESSION_ID_1, sessionIndex = 0, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) } @@ -108,7 +85,7 @@ class SessionGeneratorTest { val sessionGenerator = SessionGenerator(timeProvider = FakeTimeProvider(), uuidGenerator = FakeUuidGenerator()) - val firstSessionDetails = sessionGenerator.generateNewSession() + val firstSessionDetails = sessionGenerator.generateNewSession(currentSession = null) assertThat(isValidSessionId(firstSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(firstSessionDetails.firstSessionId)).isTrue() @@ -119,11 +96,12 @@ class SessionGeneratorTest { sessionId = SESSION_ID_1, firstSessionId = SESSION_ID_1, sessionIndex = 0, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) - val secondSessionDetails = sessionGenerator.generateNewSession() + val secondSessionDetails = + sessionGenerator.generateNewSession(currentSession = firstSessionDetails) assertThat(isValidSessionId(secondSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(secondSessionDetails.firstSessionId)).isTrue() @@ -135,12 +113,13 @@ class SessionGeneratorTest { sessionId = SESSION_ID_2, firstSessionId = SESSION_ID_1, sessionIndex = 1, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) // Do a third round just in case - val thirdSessionDetails = sessionGenerator.generateNewSession() + val thirdSessionDetails = + sessionGenerator.generateNewSession(currentSession = secondSessionDetails) assertThat(isValidSessionId(thirdSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(thirdSessionDetails.firstSessionId)).isTrue() @@ -151,7 +130,7 @@ class SessionGeneratorTest { sessionId = SESSION_ID_3, firstSessionId = SESSION_ID_1, sessionIndex = 2, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt deleted file mode 100644 index 12a017a7462..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.os.Looper -import androidx.test.core.app.ApplicationProvider -import androidx.test.filters.MediumTest -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.initialize -import com.google.firebase.sessions.api.FirebaseSessionsDependencies -import com.google.firebase.sessions.api.SessionSubscriber -import com.google.firebase.sessions.api.SessionSubscriber.SessionDetails -import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FakeSessionLifecycleServiceBinder -import com.google.firebase.sessions.testing.FakeSessionSubscriber -import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf - -@OptIn(ExperimentalCoroutinesApi::class) -@MediumTest -@RunWith(RobolectricTestRunner::class) -internal class SessionLifecycleClientTest { - private lateinit var fakeService: FakeSessionLifecycleServiceBinder - private lateinit var lifecycleServiceBinder: SessionLifecycleServiceBinder - - @Before - fun setUp() { - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - - fakeService = FirebaseSessionsFakeComponent.instance.fakeSessionLifecycleServiceBinder - lifecycleServiceBinder = FirebaseSessionsFakeComponent.instance.sessionLifecycleServiceBinder - } - - @After - fun cleanUp() { - fakeService.serviceDisconnected() - FirebaseApp.clearInstancesForTest() - fakeService.clearForTest() - FirebaseSessionsDependencies.reset() - } - - @Test - fun bindToService_registersCallbacks() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - waitForMessages() - assertThat(fakeService.clientCallbacks).hasSize(1) - assertThat(fakeService.connectionCallbacks).hasSize(1) - } - - @Test - fun onServiceConnected_sendsQueuedMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - client.foregrounded() - client.backgrounded() - - fakeService.serviceConnected() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun onServiceConnected_sendsOnlyLatestMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - client.foregrounded() - client.backgrounded() - client.foregrounded() - client.backgrounded() - client.foregrounded() - client.backgrounded() - - fakeService.serviceConnected() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun onServiceDisconnected_noMoreEventsSent() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.serviceDisconnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).isEmpty() - } - - @Test - fun serviceReconnection_handlesNewMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.serviceDisconnected() - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun serviceReconnection_queuesOldMessages() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.serviceDisconnected() - client.foregrounded() - client.backgrounded() - fakeService.serviceConnected() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes) - .containsExactly(SessionLifecycleService.FOREGROUNDED, SessionLifecycleService.BACKGROUNDED) - } - - @Test - fun doesNotSendLifecycleEventsWithoutSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).isEmpty() - } - - @Test - fun doesNotSendLifecycleEventsWithoutEnabledSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(collectionEnabled = false, SessionSubscriber.Name.CRASHLYTICS) - addSubscriber(collectionEnabled = false, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).isEmpty() - } - - @Test - fun sendsLifecycleEventsWhenAtLeastOneEnabledSubscriber() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - addSubscriber(collectionEnabled = true, SessionSubscriber.Name.CRASHLYTICS) - addSubscriber(collectionEnabled = false, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - client.foregrounded() - client.backgrounded() - - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(2) - } - - @Test - fun handleSessionUpdate_noSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.broadcastSession("123") - - waitForMessages() - } - - @Test - fun handleSessionUpdate_sendsToSubscribers() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - val crashlyticsSubscriber = addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - val mattSaysHiSubscriber = addSubscriber(true, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.broadcastSession("123") - - waitForMessages() - assertThat(crashlyticsSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - assertThat(mattSaysHiSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - } - - @Test - fun handleSessionUpdate_sendsToAllSubscribersAsLongAsOneIsEnabled() = - runTest(UnconfinedTestDispatcher()) { - val client = SessionLifecycleClient(backgroundDispatcher() + coroutineContext) - val crashlyticsSubscriber = addSubscriber(true, SessionSubscriber.Name.CRASHLYTICS) - val mattSaysHiSubscriber = addSubscriber(false, SessionSubscriber.Name.MATT_SAYS_HI) - client.bindToService(lifecycleServiceBinder) - - fakeService.serviceConnected() - fakeService.broadcastSession("123") - - waitForMessages() - assertThat(crashlyticsSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - assertThat(mattSaysHiSubscriber.sessionChangedEvents).containsExactly(SessionDetails("123")) - } - - private fun addSubscriber( - collectionEnabled: Boolean, - name: SessionSubscriber.Name, - ): FakeSessionSubscriber { - val fakeSubscriber = FakeSessionSubscriber(collectionEnabled, sessionSubscriberName = name) - FirebaseSessionsDependencies.addDependency(name) - FirebaseSessionsDependencies.register(fakeSubscriber) - return fakeSubscriber - } - - private fun waitForMessages() { - shadowOf(Looper.getMainLooper()).idle() - } - - private fun backgroundDispatcher() = TestOnlyExecutors.background().asCoroutineDispatcher() -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt deleted file mode 100644 index ccd933f1213..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.content.Intent -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.os.Messenger -import androidx.test.core.app.ApplicationProvider -import androidx.test.filters.MediumTest -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.initialize -import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent -import java.time.Duration -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.android.controller.ServiceController -import org.robolectric.annotation.LooperMode -import org.robolectric.annotation.LooperMode.Mode.PAUSED -import org.robolectric.shadows.ShadowSystemClock - -@MediumTest -@LooperMode(PAUSED) -@RunWith(RobolectricTestRunner::class) -internal class SessionLifecycleServiceTest { - private lateinit var service: ServiceController - - data class CallbackMessage(val code: Int, val sessionId: String?) - - internal inner class TestCallbackHandler(looper: Looper = Looper.getMainLooper()) : - Handler(looper) { - val callbackMessages = ArrayList() - - override fun handleMessage(msg: Message) { - callbackMessages.add(CallbackMessage(msg.what, getSessionId(msg))) - } - } - - @Before - fun setUp() { - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - service = createService() - } - - @After - fun cleanUp() { - FirebaseApp.clearInstancesForTest() - } - - @Test - fun binding_noCallbackOnInitialBindingWhenNoneStored() { - val client = TestCallbackHandler() - - bindToService(client) - - waitForAllMessages() - assertThat(client.callbackMessages).isEmpty() - } - - @Test - fun binding_callbackOnInitialBindWhenSessionIdSet() { - val client = TestCallbackHandler() - FirebaseSessionsFakeComponent.instance.fakeSessionDatastore.updateSessionId("123") - - bindToService(client) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(1) - val msg = client.callbackMessages.first() - assertThat(msg.code).isEqualTo(SessionLifecycleService.SESSION_UPDATED) - assertThat(msg.sessionId).isNotEmpty() - // We should not send stored session IDs to firelog - assertThat(getUploadedSessions()).isEmpty() - } - - @Test - fun foregrounding_startsSessionOnFirstForegrounding() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(1) - assertThat(getUploadedSessions()).hasSize(1) - assertThat(client.callbackMessages.first().code) - .isEqualTo(SessionLifecycleService.SESSION_UPDATED) - assertThat(client.callbackMessages.first().sessionId).isNotEmpty() - assertThat(getUploadedSessions().first().sessionId) - .isEqualTo(client.callbackMessages.first().sessionId) - } - - @Test - fun foregrounding_onlyOneSessionOnMultipleForegroundings() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(1) - assertThat(getUploadedSessions()).hasSize(1) - } - - @Test - fun foregrounding_newSessionAfterLongDelay() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - ShadowSystemClock.advanceBy(Duration.ofMinutes(31)) - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).hasSize(2) - assertThat(getUploadedSessions()).hasSize(2) - assertThat(client.callbackMessages.first().sessionId) - .isNotEqualTo(client.callbackMessages.last().sessionId) - assertThat(getUploadedSessions().first().sessionId) - .isEqualTo(client.callbackMessages.first().sessionId) - assertThat(getUploadedSessions().last().sessionId) - .isEqualTo(client.callbackMessages.last().sessionId) - } - - @Test - fun sendsSessionsToMultipleClients() { - val client1 = TestCallbackHandler() - val client2 = TestCallbackHandler() - val client3 = TestCallbackHandler() - bindToService(client1) - val messenger = bindToService(client2) - bindToService(client3) - waitForAllMessages() - - messenger.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client1.callbackMessages).hasSize(1) - assertThat(client1.callbackMessages).isEqualTo(client2.callbackMessages) - assertThat(client1.callbackMessages).isEqualTo(client3.callbackMessages) - assertThat(getUploadedSessions()).hasSize(1) - } - - @Test - fun onlyOneSessionForMultipleClientsForegrounding() { - val client1 = TestCallbackHandler() - val client2 = TestCallbackHandler() - val client3 = TestCallbackHandler() - val messenger1 = bindToService(client1) - val messenger2 = bindToService(client2) - val messenger3 = bindToService(client3) - waitForAllMessages() - - messenger1.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger1.send(Message.obtain(null, SessionLifecycleService.BACKGROUNDED, 0, 0)) - messenger2.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - messenger2.send(Message.obtain(null, SessionLifecycleService.BACKGROUNDED, 0, 0)) - messenger3.send(Message.obtain(null, SessionLifecycleService.FOREGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client1.callbackMessages).hasSize(1) - assertThat(client1.callbackMessages).isEqualTo(client2.callbackMessages) - assertThat(client1.callbackMessages).isEqualTo(client3.callbackMessages) - assertThat(getUploadedSessions()).hasSize(1) - } - - @Test - fun backgrounding_doesNotStartSession() { - val client = TestCallbackHandler() - val messenger = bindToService(client) - - messenger.send(Message.obtain(null, SessionLifecycleService.BACKGROUNDED, 0, 0)) - - waitForAllMessages() - assertThat(client.callbackMessages).isEmpty() - assertThat(getUploadedSessions()).isEmpty() - } - - private fun bindToService(client: TestCallbackHandler): Messenger { - return Messenger(service.get()?.onBind(createServiceLaunchIntent(client))) - } - - private fun createServiceLaunchIntent(client: TestCallbackHandler) = - Intent(ApplicationProvider.getApplicationContext(), SessionLifecycleService::class.java).apply { - putExtra(SessionLifecycleService.CLIENT_CALLBACK_MESSENGER, Messenger(client)) - } - - private fun createService() = - Robolectric.buildService(SessionLifecycleService::class.java).create() - - private fun waitForAllMessages() { - shadowOf(service.get()?.handlerThread?.getLooper()).idle() - shadowOf(Looper.getMainLooper()).idle() - } - - private fun getUploadedSessions() = - FirebaseSessionsFakeComponent.instance.fakeFirelogPublisher.loggedSessions - - private fun getSessionId(msg: Message) = - msg.data?.getString(SessionLifecycleService.SESSION_UPDATE_EXTRA) -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt deleted file mode 100644 index 62e650d90c8..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions - -import android.app.Activity -import android.os.Looper -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.google.firebase.Firebase -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.initialize -import com.google.firebase.sessions.api.FirebaseSessionsDependencies -import com.google.firebase.sessions.api.SessionSubscriber -import com.google.firebase.sessions.testing.FakeFirebaseApp -import com.google.firebase.sessions.testing.FakeSessionLifecycleServiceBinder -import com.google.firebase.sessions.testing.FakeSessionSubscriber -import com.google.firebase.sessions.testing.FirebaseSessionsFakeComponent -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Shadows - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -internal class SessionsActivityLifecycleCallbacksTest { - private lateinit var fakeService: FakeSessionLifecycleServiceBinder - private lateinit var lifecycleServiceBinder: SessionLifecycleServiceBinder - private val fakeActivity = Activity() - - @Before - fun setUp() { - // Reset the state of the SessionsActivityLifecycleCallbacks object. - SessionsActivityLifecycleCallbacks.hasPendingForeground = false - SessionsActivityLifecycleCallbacks.lifecycleClient = null - - FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.MATT_SAYS_HI) - FirebaseSessionsDependencies.register( - FakeSessionSubscriber( - isDataCollectionEnabled = true, - sessionSubscriberName = SessionSubscriber.Name.MATT_SAYS_HI, - ) - ) - - Firebase.initialize( - ApplicationProvider.getApplicationContext(), - FirebaseOptions.Builder() - .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) - .setApiKey(FakeFirebaseApp.MOCK_API_KEY) - .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build(), - ) - - fakeService = FirebaseSessionsFakeComponent.instance.fakeSessionLifecycleServiceBinder - lifecycleServiceBinder = FirebaseSessionsFakeComponent.instance.sessionLifecycleServiceBinder - } - - @After - fun cleanUp() { - fakeService.serviceDisconnected() - FirebaseApp.clearInstancesForTest() - fakeService.clearForTest() - FirebaseSessionsDependencies.reset() - } - - @Test - fun hasPendingForeground_thenSetLifecycleClient_callsBackgrounded() = - runTest(UnconfinedTestDispatcher()) { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher(coroutineContext)) - - // Activity comes to foreground before the lifecycle client was set due to no settings. - SessionsActivityLifecycleCallbacks.onActivityResumed(fakeActivity) - - // Settings fetched and set the lifecycle client. - lifecycleClient.bindToService(lifecycleServiceBinder) - fakeService.serviceConnected() - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - - // Assert lifecycleClient.foregrounded got called. - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(1) - } - - @Test - fun noPendingForeground_thenSetLifecycleClient_doesNotCallBackgrounded() = - runTest(UnconfinedTestDispatcher()) { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher(coroutineContext)) - - // Set lifecycle client before any foreground happened. - lifecycleClient.bindToService(lifecycleServiceBinder) - fakeService.serviceConnected() - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - - // Assert lifecycleClient.foregrounded did not get called. - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(0) - - // Activity comes to foreground. - SessionsActivityLifecycleCallbacks.onActivityResumed(fakeActivity) - - // Assert lifecycleClient.foregrounded did get called. - waitForMessages() - assertThat(fakeService.receivedMessageCodes).hasSize(1) - } - - private fun waitForMessages() = Shadows.shadowOf(Looper.getMainLooper()).idle() - - private fun backgroundDispatcher(coroutineContext: CoroutineContext) = - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt new file mode 100644 index 00000000000..5adc708e36b --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.concurrent.TestOnlyExecutors +import com.google.firebase.sessions.SessionGeneratorTest.Companion.SESSION_ID_1 +import com.google.firebase.sessions.SessionGeneratorTest.Companion.SESSION_ID_2 +import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.testing.FakeDataStore +import com.google.firebase.sessions.testing.FakeEventGDTLogger +import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeFirebaseInstallations +import com.google.firebase.sessions.testing.FakeProcessDataManager +import com.google.firebase.sessions.testing.FakeSettingsProvider +import com.google.firebase.sessions.testing.FakeTimeProvider +import com.google.firebase.sessions.testing.FakeUuidGenerator +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SharedSessionRepositoryTest { + private val fakeFirebaseApp = FakeFirebaseApp() + private val fakeEventGDTLogger = FakeEventGDTLogger() + private val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD", "FakeAuthToken") + private var fakeTimeProvider = FakeTimeProvider() + private val sessionGenerator = SessionGenerator(fakeTimeProvider, FakeUuidGenerator()) + private var localSettingsProvider = FakeSettingsProvider(true, null, 100.0) + private var remoteSettingsProvider = FakeSettingsProvider(true, null, 100.0) + private var sessionsSettings = SessionsSettings(localSettingsProvider, remoteSettingsProvider) + + @After + fun cleanUp() { + FirebaseApp.clearInstancesForTest() + } + + @Test + fun initSharedSessionRepo_readFromDatastore() = runTest { + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings = sessionsSettings, + sessionGenerator = sessionGenerator, + sessionFirelogPublisher = publisher, + timeProvider = fakeTimeProvider, + sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + fakeDataStore.close() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_INIT) + } + + @Test + fun initSharedSessionRepo_coldStart() = runTest { + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + backgroundTime = fakeTimeProvider.currentTime(), + processDataMap = mapOf("" to ProcessData(pid = 10, uuid = "uuid")), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings = sessionsSettings, + sessionGenerator = sessionGenerator, + sessionFirelogPublisher = publisher, + timeProvider = fakeTimeProvider, + sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(coldStart = true), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + + sharedSessionRepository.appForeground() + runCurrent() + fakeDataStore.close() + + assertThat(sharedSessionRepository.localSessionData.sessionDetails) + .isEqualTo(SessionDetails(SESSION_ID_1, SESSION_ID_1, 0, fakeTimeProvider.currentTime().us)) + } + + @Test + fun initSharedSessionRepo_initException() = runTest { + val sessionFirelogPublisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ), + IllegalArgumentException("Datastore init failed"), + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + fakeDataStore.close() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_1) + } + + @Test + fun appForegroundGenerateNewSession_updateSuccess() = runTest { + val sessionFirelogPublisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + backgroundTime = fakeTimeProvider.currentTime(), + processDataMap = mapOf("" to ProcessData(pid = 10, uuid = "uuid")), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + + fakeTimeProvider.addInterval(20.hours) + sharedSessionRepository.appForeground() + runCurrent() + + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_1) + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull() + assertThat(sharedSessionRepository.previousNotificationType) + .isEqualTo(SharedSessionRepositoryImpl.NotificationType.GENERAL) + fakeDataStore.close() + } + + @Test + fun appForegroundGenerateNewSession_updateFail() = runTest { + val sessionFirelogPublisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ), + IllegalArgumentException("Datastore init failed"), + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + runCurrent() + + // set background time first + fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed")) + sharedSessionRepository.appBackground() + runCurrent() + + // foreground update session + fakeTimeProvider.addInterval(20.hours) + fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed")) + sharedSessionRepository.appForeground() + runCurrent() + + // session_2 here because session_1 is failed when try to init datastore + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_2) + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull() + assertThat(sharedSessionRepository.previousNotificationType) + .isEqualTo(SharedSessionRepositoryImpl.NotificationType.FALLBACK) + fakeDataStore.close() + } + + companion object { + const val SESSION_ID_INIT = "12345678901234546677960" + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/CrashEventReceiverTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/CrashEventReceiverTest.kt new file mode 100644 index 00000000000..7f677bf64a7 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/CrashEventReceiverTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.concurrent.TestOnlyExecutors +import com.google.firebase.sessions.SessionData +import com.google.firebase.sessions.SessionDetails +import com.google.firebase.sessions.SessionFirelogPublisherImpl +import com.google.firebase.sessions.SessionGenerator +import com.google.firebase.sessions.SharedSessionRepositoryImpl +import com.google.firebase.sessions.SharedSessionRepositoryTest.Companion.SESSION_ID_INIT +import com.google.firebase.sessions.settings.SessionsSettings +import com.google.firebase.sessions.testing.FakeDataStore +import com.google.firebase.sessions.testing.FakeEventGDTLogger +import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeFirebaseInstallations +import com.google.firebase.sessions.testing.FakeProcessDataManager +import com.google.firebase.sessions.testing.FakeSettingsProvider +import com.google.firebase.sessions.testing.FakeTimeProvider +import com.google.firebase.sessions.testing.FakeUuidGenerator +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class CrashEventReceiverTest { + @Test + fun notifyCrashOccurredOnForegroundOnly() = runTest { + // Setup + val fakeFirebaseApp = FakeFirebaseApp() + val fakeEventGDTLogger = FakeEventGDTLogger() + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD", "FakeAuthToken") + val fakeTimeProvider = FakeTimeProvider() + val sessionGenerator = SessionGenerator(fakeTimeProvider, FakeUuidGenerator()) + val localSettingsProvider = FakeSettingsProvider(true, null, 100.0) + val remoteSettingsProvider = FakeSettingsProvider(true, null, 100.0) + val sessionsSettings = SessionsSettings(localSettingsProvider, remoteSettingsProvider) + val publisher = + SessionFirelogPublisherImpl( + fakeFirebaseApp.firebaseApp, + firebaseInstallations, + sessionsSettings, + eventGDTLogger = fakeEventGDTLogger, + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + val fakeDataStore = + FakeDataStore( + SessionData( + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + fakeTimeProvider.currentTime(), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings = sessionsSettings, + sessionGenerator = sessionGenerator, + sessionFirelogPublisher = publisher, + timeProvider = fakeTimeProvider, + sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(), + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + ) + CrashEventReceiver.sharedSessionRepository = sharedSessionRepository + + runCurrent() + + // Process starts in the background + assertThat(sharedSessionRepository.isInForeground).isFalse() + + // This will not update background time since the process is already in the background + val originalBackgroundTime = fakeTimeProvider.currentTime() + CrashEventReceiver.notifyCrashOccurred() + assertThat(sharedSessionRepository.localSessionData.backgroundTime) + .isEqualTo(originalBackgroundTime) + + // Wait a bit, then bring the process to foreground + fakeTimeProvider.addInterval(31.minutes) + sharedSessionRepository.appForeground() + + runCurrent() + + // The background time got cleared + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull() + + // Wait a bit, then notify of a crash + fakeTimeProvider.addInterval(3.seconds) + val newBackgroundTime = fakeTimeProvider.currentTime() + CrashEventReceiver.notifyCrashOccurred() + + runCurrent() + + // Verify the background time got updated + assertThat(sharedSessionRepository.localSessionData.backgroundTime).isEqualTo(newBackgroundTime) + + // Clean up + fakeDataStore.close() + FirebaseApp.clearInstancesForTest() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt index f662fe13e90..d9095dbbd7d 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/api/FirebaseSessionsDependenciesTest.kt @@ -23,7 +23,6 @@ import com.google.firebase.sessions.api.SessionSubscriber.Name.MATT_SAYS_HI import com.google.firebase.sessions.testing.FakeSessionSubscriber import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -34,7 +33,6 @@ import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class FirebaseSessionsDependenciesTest { @After diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt index e4fb0b00148..74df328ae57 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt @@ -16,28 +16,20 @@ package com.google.firebase.sessions.settings -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.preferencesDataStoreFile import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.installations.FirebaseInstallationsApi -import com.google.firebase.sessions.ApplicationInfo import com.google.firebase.sessions.SessionEvents import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher -import kotlin.coroutines.CoroutineContext +import com.google.firebase.sessions.testing.FakeSettingsCache +import com.google.firebase.sessions.testing.FakeTimeProvider import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout import org.json.JSONObject @@ -45,261 +37,204 @@ import org.junit.After import org.junit.Test import org.junit.runner.RunWith -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(AndroidJUnit4::class) class RemoteSettingsTest { @Test - fun remoteSettings_successfulFetchCachesValues() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isNull() - assertThat(remoteSettings.sessionRestartTimeout).isNull() - - fakeFetcher.responseJSONObject = JSONObject(VALID_RESPONSE) - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulFetchCachesValues() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isNull() + assertThat(remoteSettings.sessionRestartTimeout).isNull() + + fakeFetcher.responseJSONObject = JSONObject(VALID_RESPONSE) + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_successfulFetchWithLessConfigsCachesOnlyReceivedValues() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isNull() - assertThat(remoteSettings.sessionRestartTimeout).isNull() - - val fetchedResponse = JSONObject(VALID_RESPONSE) - fetchedResponse.getJSONObject("app_quality").remove("sessions_enabled") - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulFetchWithLessConfigsCachesOnlyReceivedValues() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isNull() + assertThat(remoteSettings.sessionRestartTimeout).isNull() + + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").remove("sessions_enabled") + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_successfulReFetchUpdatesCache() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val fetchedResponse = JSONObject(VALID_RESPONSE) - fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - fetchedResponse.getJSONObject("app_quality").put("sessions_enabled", true) - fetchedResponse.getJSONObject("app_quality").put("sampling_rate", 0.25) - fetchedResponse.getJSONObject("app_quality").put("session_timeout_seconds", 1200) - - // TODO(mrober): Fix these so we don't need to sleep. Maybe use FakeTime? - // Sleep for a second before updating configs - Thread.sleep(2000) - - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isTrue() - assertThat(remoteSettings.samplingRate).isEqualTo(0.25) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulReFetchUpdatesCache() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val fakeTimeProvider = FakeTimeProvider() + + val remoteSettings = + RemoteSettings( + fakeTimeProvider, + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(fakeTimeProvider), + ) + + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + fetchedResponse.getJSONObject("app_quality").put("sessions_enabled", true) + fetchedResponse.getJSONObject("app_quality").put("sampling_rate", 0.25) + fetchedResponse.getJSONObject("app_quality").put("session_timeout_seconds", 1200) + + fakeTimeProvider.addInterval(31.minutes) + + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isTrue() + assertThat(remoteSettings.samplingRate).isEqualTo(0.25) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_successfulFetchWithEmptyConfigRetainsOldConfigs() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val fetchedResponse = JSONObject(VALID_RESPONSE) - fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - fetchedResponse.remove("app_quality") - - // Sleep for a second before updating configs - Thread.sleep(2000) - - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() - - runCurrent() - - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + fun remoteSettings_successfulFetchWithEmptyConfigRetainsOldConfigs() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val fakeTimeProvider = FakeTimeProvider() + + val remoteSettings = + RemoteSettings( + fakeTimeProvider, + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + fakeTimeProvider.addInterval(31.seconds) + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + fetchedResponse.remove("app_quality") + + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() + + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + remoteSettings.clearCachedSettings() + } @Test - fun remoteSettings_fetchWhileFetchInProgress() = - runTest(UnconfinedTestDispatcher()) { - // This test does: - // 1. Do a fetch with a fake fetcher that will block for 3 seconds. - // 2. While that is happening, do a second fetch. - // - First fetch is still fetching, so second fetch should fall through to the mutex. - // - Second fetch will be blocked until first completes. - // - First fetch returns, should unblock the second fetch. - // - Second fetch should go into mutex, sees cache is valid in "double check," exist early. - // 3. After a fetch completes, do a third fetch. - // - First fetch should have have updated the cache. - // - Third fetch should exit even earlier, never having gone into the mutex. - - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcherWithDelay = - FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE), networkDelay = 3.seconds) - - fakeFetcherWithDelay.responseJSONObject - .getJSONObject("app_quality") - .put("sampling_rate", 0.125) - - val remoteSettingsWithDelay = - buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcherWithDelay, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - // Do the first fetch. This one should fetched the configsFetcher. - val firstFetch = launch(Dispatchers.Default) { remoteSettingsWithDelay.updateSettings() } - - // Wait a second, and then do the second fetch while first is still running. - // This one should block until the first fetch completes, but then exit early. - launch(Dispatchers.Default) { - delay(1.seconds) - remoteSettingsWithDelay.updateSettings() - } + fun remoteSettings_fetchWhileFetchInProgress() = runTest { + // This test does: + // 1. Do a fetch with a fake fetcher that will block for 3 seconds. + // 2. While that is happening, do a second fetch. + // - First fetch is still fetching, so second fetch should fall through to the mutex. + // - Second fetch will be blocked until first completes. + // - First fetch returns, should unblock the second fetch. + // - Second fetch should go into mutex, sees cache is valid in "double check," exist early. + // 3. After a fetch completes, do a third fetch. + // - First fetch should have have updated the cache. + // - Third fetch should exit even earlier, never having gone into the mutex. + + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcherWithDelay = + FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE), networkDelay = 3.seconds) + + fakeFetcherWithDelay.responseJSONObject.getJSONObject("app_quality").put("sampling_rate", 0.125) + + val remoteSettingsWithDelay = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + configsFetcher = fakeFetcherWithDelay, + FakeSettingsCache(), + ) + + // Do the first fetch. This one should fetched the configsFetcher. + val firstFetch = launch(Dispatchers.Default) { remoteSettingsWithDelay.updateSettings() } + + // Wait a second, and then do the second fetch while first is still running. + // This one should block until the first fetch completes, but then exit early. + launch(Dispatchers.Default) { + delay(1.seconds) + remoteSettingsWithDelay.updateSettings() + } - // Wait until the first fetch is done, then do a third fetch. - // This one should not even block, and exit early. - firstFetch.join() - withTimeout(1.seconds) { remoteSettingsWithDelay.updateSettings() } + // Wait until the first fetch is done, then do a third fetch. + // This one should not even block, and exit early. + firstFetch.join() + withTimeout(1.seconds) { remoteSettingsWithDelay.updateSettings() } - // Assert that the configsFetcher was fetched exactly once. - assertThat(fakeFetcherWithDelay.timesCalled).isEqualTo(1) - assertThat(remoteSettingsWithDelay.samplingRate).isEqualTo(0.125) - } + // Assert that the configsFetcher was fetched exactly once. + assertThat(fakeFetcherWithDelay.timesCalled).isEqualTo(1) + assertThat(remoteSettingsWithDelay.samplingRate).isEqualTo(0.125) + } @After fun cleanUp() { FirebaseApp.clearInstancesForTest() } - internal companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_session_settings_test" - + private companion object { const val VALID_RESPONSE = """ { @@ -318,30 +253,5 @@ class RemoteSettingsTest { } } """ - - /** - * Build an instance of [RemoteSettings] using the Dagger factory. - * - * This is needed because the SDK vendors Dagger to a difference namespace, but it does not for - * these unit tests. The [RemoteSettings.lazySettingsCache] has type [dagger.Lazy] in these - * tests, but type `com.google.firebase.sessions.dagger.Lazy` in the SDK. This method to build - * the instance is the easiest I could find that does not need any reference to [dagger.Lazy] in - * the test code. - */ - fun buildRemoteSettings( - backgroundDispatcher: CoroutineContext, - firebaseInstallationsApi: FirebaseInstallationsApi, - appInfo: ApplicationInfo, - configsFetcher: CrashlyticsSettingsFetcher, - settingsCache: SettingsCache, - ): RemoteSettings = - RemoteSettings_Factory.create( - { backgroundDispatcher }, - { firebaseInstallationsApi }, - { appInfo }, - { configsFetcher }, - { settingsCache }, - ) - .get() } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt index 12f40e7cca8..146857ae7f4 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SessionsSettingsTest.kt @@ -17,22 +17,16 @@ package com.google.firebase.sessions.settings import android.os.Bundle -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.preferencesDataStoreFile import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.concurrent.TestOnlyExecutors -import com.google.firebase.sessions.SessionDataStoreConfigs import com.google.firebase.sessions.SessionEvents import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher +import com.google.firebase.sessions.testing.FakeSettingsCache import com.google.firebase.sessions.testing.FakeSettingsProvider +import com.google.firebase.sessions.testing.FakeTimeProvider import kotlin.time.Duration.Companion.minutes -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.After @@ -40,7 +34,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SessionsSettingsTest { @@ -91,147 +84,116 @@ class SessionsSettingsTest { remoteSettings = FakeSettingsProvider(), ) - runCurrent() - assertThat(sessionsSettings.sessionsEnabled).isFalse() assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(30.minutes) } @Test - fun sessionSettings_remoteSettingsOverrideDefaultsWhenPresent() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) - - val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) + fun sessionSettings_remoteSettingsOverrideDefaultsWhenPresent() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val context = firebaseApp.applicationContext + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - sessionsSettings.updateSettings() + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) - runCurrent() + sessionsSettings.updateSettings() - assertThat(sessionsSettings.sessionsEnabled).isFalse() - assertThat(sessionsSettings.samplingRate).isEqualTo(0.75) - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) + assertThat(sessionsSettings.sessionsEnabled).isFalse() + assertThat(sessionsSettings.samplingRate).isEqualTo(0.75) + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) - remoteSettings.clearCachedSettings() - } + remoteSettings.clearCachedSettings() + } @Test - fun sessionSettings_manifestOverridesRemoteSettingsAndDefaultsWhenPresent() = - runTest(UnconfinedTestDispatcher()) { - val metadata = Bundle() - metadata.putBoolean("firebase_sessions_enabled", true) - metadata.putDouble("firebase_sessions_sampling_rate", 0.5) - metadata.putInt("firebase_sessions_sessions_restart_timeout", 180) - val firebaseApp = FakeFirebaseApp(metadata).firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) - - val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) + fun sessionSettings_manifestOverridesRemoteSettingsAndDefaultsWhenPresent() = runTest { + val metadata = Bundle() + metadata.putBoolean("firebase_sessions_enabled", true) + metadata.putDouble("firebase_sessions_sampling_rate", 0.5) + metadata.putInt("firebase_sessions_sessions_restart_timeout", 180) + val firebaseApp = FakeFirebaseApp(metadata).firebaseApp + val context = firebaseApp.applicationContext + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - sessionsSettings.updateSettings() + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) - runCurrent() + sessionsSettings.updateSettings() - assertThat(sessionsSettings.sessionsEnabled).isTrue() - assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(3.minutes) + assertThat(sessionsSettings.sessionsEnabled).isTrue() + assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(3.minutes) - remoteSettings.clearCachedSettings() - } + remoteSettings.clearCachedSettings() + } @Test - fun sessionSettings_invalidManifestConfigsDoNotOverride() = - runTest(UnconfinedTestDispatcher()) { - val metadata = Bundle() - metadata.putBoolean("firebase_sessions_enabled", false) - metadata.putDouble("firebase_sessions_sampling_rate", -0.2) // Invalid - metadata.putInt("firebase_sessions_sessions_restart_timeout", -2) // Invalid - val firebaseApp = FakeFirebaseApp(metadata).firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - val invalidResponse = - VALID_RESPONSE.replace( - "\"sampling_rate\":0.75,", - "\"sampling_rate\":1.2,", // Invalid - ) - fakeFetcher.responseJSONObject = JSONObject(invalidResponse) - - val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) - - sessionsSettings.updateSettings() + fun sessionSettings_invalidManifestConfigsDoNotOverride() = runTest { + val metadata = Bundle() + metadata.putBoolean("firebase_sessions_enabled", false) + metadata.putDouble("firebase_sessions_sampling_rate", -0.2) // Invalid + metadata.putInt("firebase_sessions_sessions_restart_timeout", -2) // Invalid + val firebaseApp = FakeFirebaseApp(metadata).firebaseApp + val context = firebaseApp.applicationContext + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val invalidResponse = + VALID_RESPONSE.replace( + "\"sampling_rate\":0.75,", + "\"sampling_rate\":1.2,", // Invalid + ) + fakeFetcher.responseJSONObject = JSONObject(invalidResponse) + + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - runCurrent() + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) - assertThat(sessionsSettings.sessionsEnabled).isFalse() // Manifest - assertThat(sessionsSettings.samplingRate).isEqualTo(1.0) // SDK default - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) // Remote + sessionsSettings.updateSettings() - remoteSettings.clearCachedSettings() - } + assertThat(sessionsSettings.sessionsEnabled).isFalse() // Manifest + assertThat(sessionsSettings.samplingRate).isEqualTo(1.0) // SDK default + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) // Remote - @Test - fun sessionSettings_dataStorePreferencesNameIsFilenameSafe() { - assertThat(SessionDataStoreConfigs.SESSIONS_CONFIG_NAME).matches("^[a-zA-Z0-9_=]+\$") + remoteSettings.clearCachedSettings() } @After @@ -240,8 +202,6 @@ class SessionsSettingsTest { } private companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_session_settings_test" - const val VALID_RESPONSE = """ { diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt index c4d35c86456..a8d8429b5a8 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/SettingsCacheTest.kt @@ -17,13 +17,16 @@ package com.google.firebase.sessions.settings import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.sessions.testing.FakeFirebaseApp +import com.google.firebase.sessions.testing.FakeTimeProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test @@ -33,13 +36,24 @@ import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SettingsCacheTest { - private val Context.dataStore: DataStore by - preferencesDataStore(name = SESSION_TEST_CONFIGS_NAME) + private val appContext: Context = ApplicationProvider.getApplicationContext() @Test fun sessionCache_returnsEmptyCache() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + runCurrent() assertThat(settingsCache.sessionSamplingRate()).isNull() assertThat(settingsCache.sessionsEnabled()).isNull() @@ -49,14 +63,28 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCachedValue() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -69,17 +97,40 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsPreviouslyStoredValue() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ) + + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = sessionConfigsDataStore, + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) // Create a new instance to imitate a second app launch. - val newSettingsCache = SettingsCache(context.dataStore) + val newSettingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = sessionConfigsDataStore, + ) + + runCurrent() assertThat(newSettingsCache.sessionsEnabled()).isFalse() assertThat(newSettingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -93,14 +144,28 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCacheExpiredWithShortCacheDuration() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(0) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 0, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() @@ -112,13 +177,28 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCachedValueWithPartialConfigs() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = null, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() @@ -130,25 +210,43 @@ class SettingsCacheTest { @Test fun settingConfigsAllowsUpdateConfigsAndCachesValues() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionRestartTimeout()).isEqualTo(600) assertThat(settingsCache.hasCacheExpired()).isFalse() - settingsCache.updateSettingsEnabled(true) - settingsCache.updateSamplingRate(0.33) - settingsCache.updateSessionRestartTimeout(100) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(0) + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = true, + sessionSamplingRate = 0.33, + sessionTimeoutSeconds = 100, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 0, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.33) assertThat(settingsCache.sessionsEnabled()).isTrue() @@ -160,25 +258,43 @@ class SettingsCacheTest { @Test fun settingConfigsCleansCacheForNullValues() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) - - settingsCache.updateSettingsEnabled(false) - settingsCache.updateSamplingRate(0.25) - settingsCache.updateSessionRestartTimeout(600) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ), + ) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionRestartTimeout()).isEqualTo(600) assertThat(settingsCache.hasCacheExpired()).isFalse() - settingsCache.updateSettingsEnabled(null) - settingsCache.updateSamplingRate(0.33) - settingsCache.updateSessionRestartTimeout(null) - settingsCache.updateSessionCacheUpdatedTime(System.currentTimeMillis()) - settingsCache.updateSessionCacheDuration(1000) + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = null, + sessionSamplingRate = 0.33, + sessionTimeoutSeconds = null, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.33) assertThat(settingsCache.sessionsEnabled()).isNull() @@ -192,8 +308,4 @@ class SettingsCacheTest { fun cleanUp() { FirebaseApp.clearInstancesForTest() } - - private companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_test_session_settings" - } } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt new file mode 100644 index 00000000000..1157a309917 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.testing + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** Fake [DataStore] that can act like an in memory data store, or throw provided exceptions. */ +@OptIn(DelicateCoroutinesApi::class) +internal class FakeDataStore( + private val firstValue: T, + private val firstThrowable: Throwable? = null, +) : DataStore { + // The channel is buffered so data can be updated without blocking until collected + // Default buffer size is 64. This makes unit tests more convenient to write + private val channel = Channel<() -> T>(Channel.BUFFERED) + private var value = firstValue + + private var throwOnUpdateData: Throwable? = null + + override val data: Flow = flow { + // If a first throwable is set, simply throw it + // This is intended to simulate a failure on init + if (firstThrowable != null) { + throw firstThrowable + } + + // Otherwise, emit the first value + emit(firstValue) + + // Start receiving values on the channel, and emit them + // The values are updated by updateData or throwOnNextEmit + try { + while (true) { + // Invoke the lambda in the channel + // Either emit the value, or throw + emit(channel.receive().invoke()) + } + } catch (_: ClosedReceiveChannelException) { + // Expected when the channel is closed + } + } + + override suspend fun updateData(transform: suspend (t: T) -> T): T { + // Check for a throwable to throw on this call to update data + val throwable = throwOnUpdateData + if (throwable != null) { + // Clear the throwable since it should only throw once + throwOnUpdateData = null + throw throwable + } + + // Apply the transformation and send it to the channel + val transformedValue = transform(value) + value = transformedValue + if (!channel.isClosedForSend) { + channel.send { transformedValue } + } + + return transformedValue + } + + /** Set an exception to throw on the next call to [updateData]. */ + fun throwOnNextUpdateData(throwable: Throwable) { + throwOnUpdateData = throwable + } + + /** Set an exception to throw on the next emit. */ + suspend fun throwOnNextEmit(throwable: Throwable) { + if (!channel.isClosedForSend) { + channel.send { throw throwable } + } + } + + /** Finish the test. */ + fun close() { + // Close the channel to stop the flow from emitting more values + // This might be needed if tests fail with UncompletedCoroutinesError + channel.close() + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt index eea9114b3b8..e934ada6bf0 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirebaseApp.kt @@ -29,7 +29,10 @@ import org.robolectric.Shadows import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager -internal class FakeFirebaseApp(metadata: Bundle? = null) { +internal class FakeFirebaseApp( + metadata: Bundle? = null, + processes: List = emptyList(), +) { val firebaseApp: FirebaseApp init { @@ -45,12 +48,16 @@ internal class FakeFirebaseApp(metadata: Bundle? = null) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val shadowActivityManager: ShadowActivityManager = Shadow.extract(activityManager) - val runningAppProcessInfo = ActivityManager.RunningAppProcessInfo() - runningAppProcessInfo.pid = 0 - runningAppProcessInfo.uid = 313 - runningAppProcessInfo.processName = context.packageName - runningAppProcessInfo.importance = 100 - shadowActivityManager.setProcesses(listOf(runningAppProcessInfo)) + if (processes.isEmpty()) { + val runningAppProcessInfo = ActivityManager.RunningAppProcessInfo() + runningAppProcessInfo.pid = 0 + runningAppProcessInfo.uid = 313 + runningAppProcessInfo.processName = context.packageName + runningAppProcessInfo.importance = 100 + shadowActivityManager.setProcesses(listOf(runningAppProcessInfo)) + } else { + shadowActivityManager.setProcesses(processes) + } firebaseApp = Firebase.initialize( @@ -59,7 +66,7 @@ internal class FakeFirebaseApp(metadata: Bundle? = null) { .setApplicationId(MOCK_APP_ID) .setApiKey(MOCK_API_KEY) .setProjectId(MOCK_PROJECT_ID) - .build() + .build(), ) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt deleted file mode 100644 index 2975447bbaa..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions.testing - -import com.google.firebase.sessions.SessionDetails -import com.google.firebase.sessions.SessionFirelogPublisher - -/** - * Fake implementation of [SessionFirelogPublisher] that allows for inspecting the session details - * that were sent to it. - */ -internal class FakeFirelogPublisher : SessionFirelogPublisher { - - /** All the sessions that were uploaded via this fake [SessionFirelogPublisher] */ - val loggedSessions = ArrayList() - - override fun logSession(sessionDetails: SessionDetails) { - loggedSessions.add(sessionDetails) - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt new file mode 100644 index 00000000000..d6e287196d4 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.testing + +import com.google.firebase.sessions.ProcessData +import com.google.firebase.sessions.ProcessDataManager + +/** + * Fake implementation of ProcessDataManager that returns the provided [coldStart] value for + * [isColdStart], and similar for [isMyProcessStale], until [onSessionGenerated] gets called then + * returns false. + */ +internal class FakeProcessDataManager( + private val coldStart: Boolean = false, + private var myProcessStale: Boolean = coldStart, + override val myProcessName: String = "com.google.firebase.sessions.test", + override var myPid: Int = 0, + override var myUuid: String = FakeUuidGenerator.UUID_1, +) : ProcessDataManager { + private var hasGeneratedSession: Boolean = false + + override fun isColdStart(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + return false + } + + return coldStart + } + + override fun isMyProcessStale(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + return false + } + + return myProcessStale + } + + override fun onSessionGenerated() { + hasGeneratedSession = true + } + + override fun updateProcessDataMap( + processDataMap: Map? + ): Map = processDataMap ?: emptyMap() +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt similarity index 54% rename from firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt rename to firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt index f98852032c8..1afebb2d0bb 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,19 @@ package com.google.firebase.sessions.testing -import com.google.firebase.sessions.SessionDatastore +import android.app.ActivityManager -/** - * Fake implementaiton of the [SessionDatastore] that allows for inspecting and modifying the - * currently stored values in unit tests. - */ -internal class FakeSessionDatastore : SessionDatastore { - - /** The currently stored value */ - private var currentSessionId: String? = null - - override fun updateSessionId(sessionId: String) { - currentSessionId = sessionId +/** Fake [ActivityManager.RunningAppProcessInfo] that is easy to construct. */ +internal class FakeRunningAppProcessInfo( + pid: Int = 0, + uid: Int = 313, + processName: String = "fake.process.name", + importance: Int = 100, +) : ActivityManager.RunningAppProcessInfo() { + init { + this.pid = pid + this.uid = uid + this.processName = processName + this.importance = importance } - - override fun getCurrentSessionId() = currentSessionId } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt deleted file mode 100644 index 0d4e58e2014..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions.testing - -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.os.Messenger -import com.google.firebase.sessions.SessionLifecycleService -import com.google.firebase.sessions.SessionLifecycleServiceBinder -import java.util.concurrent.LinkedBlockingQueue -import org.robolectric.Shadows.shadowOf - -/** - * Fake implementation of the [SessionLifecycleServiceBinder] that allows for inspecting the - * callbacks and received messages of the service in unit tests. - */ -internal class FakeSessionLifecycleServiceBinder : SessionLifecycleServiceBinder { - - val clientCallbacks = mutableListOf() - val connectionCallbacks = mutableListOf() - val receivedMessageCodes = LinkedBlockingQueue() - var service = Messenger(FakeServiceHandler()) - - internal inner class FakeServiceHandler() : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - receivedMessageCodes.add(msg.what) - } - } - - override fun bindToService(callback: Messenger, serviceConnection: ServiceConnection) { - clientCallbacks.add(callback) - connectionCallbacks.add(serviceConnection) - } - - fun serviceConnected() { - connectionCallbacks.forEach { it.onServiceConnected(componentName, service.getBinder()) } - } - - fun serviceDisconnected() { - connectionCallbacks.forEach { it.onServiceDisconnected(componentName) } - } - - fun broadcastSession(sessionId: String) { - clientCallbacks.forEach { client -> - val msgData = - Bundle().also { it.putString(SessionLifecycleService.SESSION_UPDATE_EXTRA, sessionId) } - client.send( - Message.obtain(null, SessionLifecycleService.SESSION_UPDATED, 0, 0).also { - it.data = msgData - } - ) - } - } - - fun waitForAllMessages() { - shadowOf(Looper.getMainLooper()).idle() - } - - fun clearForTest() { - clientCallbacks.clear() - connectionCallbacks.clear() - receivedMessageCodes.clear() - service = Messenger(FakeServiceHandler()) - } - - companion object { - val componentName = - ComponentName("com.google.firebase.sessions.testing", "FakeSessionLifecycleServiceBinder") - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt new file mode 100644 index 00000000000..2c58ef22d7d --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.sessions.testing + +import com.google.firebase.sessions.TimeProvider +import com.google.firebase.sessions.settings.SessionConfigs +import com.google.firebase.sessions.settings.SessionConfigsSerializer +import com.google.firebase.sessions.settings.SettingsCache + +/** Fake implementation of [SettingsCache]. */ +internal class FakeSettingsCache( + private val timeProvider: TimeProvider = FakeTimeProvider(), + private var sessionConfigs: SessionConfigs = SessionConfigsSerializer.defaultValue, +) : SettingsCache { + override fun hasCacheExpired(): Boolean { + val cacheUpdatedTimeSeconds = sessionConfigs.cacheUpdatedTimeSeconds + val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds + + if (cacheUpdatedTimeSeconds != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = timeProvider.currentTime().seconds - cacheUpdatedTimeSeconds + if (timeDifferenceSeconds < cacheDurationSeconds) { + return false + } + } + + return true + } + + override fun sessionsEnabled(): Boolean? = sessionConfigs.sessionsEnabled + + override fun sessionSamplingRate(): Double? = sessionConfigs.sessionSamplingRate + + override fun sessionRestartTimeout(): Int? = sessionConfigs.sessionTimeoutSeconds + + override suspend fun updateConfigs(sessionConfigs: SessionConfigs) { + this.sessionConfigs = sessionConfigs + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt index 35010de415a..295600cf48e 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTimeProvider.kt @@ -16,17 +16,19 @@ package com.google.firebase.sessions.testing +import com.google.firebase.sessions.Time import com.google.firebase.sessions.TimeProvider -import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP_US +import com.google.firebase.sessions.testing.TestSessionEventData.TEST_SESSION_TIMESTAMP import kotlin.time.Duration -import kotlin.time.DurationUnit +import kotlin.time.DurationUnit.MILLISECONDS /** * Fake [TimeProvider] that allows programmatically elapsing time forward. * * Default [elapsedRealtime] is [Duration.ZERO] until the time is moved using [addInterval]. */ -class FakeTimeProvider(private val initialTimeUs: Long = TEST_SESSION_TIMESTAMP_US) : TimeProvider { +internal class FakeTimeProvider(private val initialTime: Time = TEST_SESSION_TIMESTAMP) : + TimeProvider { private var elapsed = Duration.ZERO fun addInterval(interval: Duration) { @@ -38,5 +40,5 @@ class FakeTimeProvider(private val initialTimeUs: Long = TEST_SESSION_TIMESTAMP_ override fun elapsedRealtime(): Duration = elapsed - override fun currentTimeUs(): Long = initialTimeUs + elapsed.toLong(DurationUnit.MICROSECONDS) + override fun currentTime(): Time = Time(ms = initialTime.ms + elapsed.toLong(MILLISECONDS)) } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt index da1d273a33f..d5f7dd8f510 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeTransportFactory.kt @@ -16,7 +16,12 @@ package com.google.firebase.sessions.testing -import com.google.android.datatransport.* +import com.google.android.datatransport.Encoding +import com.google.android.datatransport.Event +import com.google.android.datatransport.Transformer +import com.google.android.datatransport.Transport +import com.google.android.datatransport.TransportFactory +import com.google.android.datatransport.TransportScheduleCallback import com.google.firebase.sessions.SessionEvent /** Fake [Transport] that implements [send]. */ @@ -34,7 +39,7 @@ internal class FakeTransport() : Transport { } /** Fake [TransportFactory] that implements [getTransport]. */ -internal class FakeTransportFactory() : TransportFactory { +internal class FakeTransportFactory : TransportFactory { var name: String? = null var payloadEncoding: Encoding? = null @@ -42,9 +47,9 @@ internal class FakeTransportFactory() : TransportFactory { override fun getTransport( name: String?, - payloadType: java.lang.Class?, + payloadType: Class?, payloadEncoding: Encoding?, - payloadTransformer: Transformer? + payloadTransformer: Transformer?, ): Transport? { this.name = name this.payloadEncoding = payloadEncoding @@ -54,11 +59,14 @@ internal class FakeTransportFactory() : TransportFactory { return fakeTransport } - @Deprecated("This is deprecated in the API. Don't use or expect on this function.") + @Deprecated( + "This is deprecated in the API. Don't use or expect on this function.", + ReplaceWith("null"), + ) override fun getTransport( name: String?, - payloadType: java.lang.Class?, - payloadTransformer: Transformer? + payloadType: Class?, + payloadTransformer: Transformer?, ): Transport? { return null } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt index 88f1f816c12..5fb2cd47785 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeUuidGenerator.kt @@ -24,6 +24,8 @@ internal class FakeUuidGenerator(private val names: List = listOf(UUID_1 UuidGenerator { private var index = -1 + constructor(vararg names: String) : this(names.toList()) + override fun next(): UUID { index = (index + 1).coerceAtMost(names.size - 1) return UUID.fromString(names[index]) diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt deleted file mode 100644 index b3431f71840..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions.testing - -import com.google.firebase.Firebase -import com.google.firebase.app -import com.google.firebase.sessions.FirebaseSessions -import com.google.firebase.sessions.FirebaseSessionsComponent -import com.google.firebase.sessions.SessionDatastore -import com.google.firebase.sessions.SessionFirelogPublisher -import com.google.firebase.sessions.SessionGenerator -import com.google.firebase.sessions.SessionLifecycleServiceBinder -import com.google.firebase.sessions.settings.SessionsSettings -import com.google.firebase.sessions.settings.SettingsProvider - -/** Fake component to manage [FirebaseSessions] and related, often faked, dependencies. */ -@Suppress("MemberVisibilityCanBePrivate") // Keep access to fakes open for convenience -internal class FirebaseSessionsFakeComponent : FirebaseSessionsComponent { - // TODO(mrober): Move tests that need DI to integration tests, and remove this component. - - // Fakes, access these instances to setup test cases, e.g., add interval to fake time provider. - val fakeTimeProvider = FakeTimeProvider() - val fakeUuidGenerator = FakeUuidGenerator() - val fakeSessionDatastore = FakeSessionDatastore() - val fakeFirelogPublisher = FakeFirelogPublisher() - val fakeSessionLifecycleServiceBinder = FakeSessionLifecycleServiceBinder() - - // Settings providers, default to fake, set these to real instances for relevant test cases. - var localOverrideSettings: SettingsProvider = FakeSettingsProvider() - var remoteSettings: SettingsProvider = FakeSettingsProvider() - - override val firebaseSessions: FirebaseSessions - get() = throw NotImplementedError("FirebaseSessions not implemented, use integration tests.") - - override val sessionDatastore: SessionDatastore = fakeSessionDatastore - - override val sessionFirelogPublisher: SessionFirelogPublisher = fakeFirelogPublisher - - override val sessionGenerator: SessionGenerator by lazy { - SessionGenerator(timeProvider = fakeTimeProvider, uuidGenerator = fakeUuidGenerator) - } - - override val sessionsSettings: SessionsSettings by lazy { - SessionsSettings(localOverrideSettings, remoteSettings) - } - - val sessionLifecycleServiceBinder: SessionLifecycleServiceBinder - get() = fakeSessionLifecycleServiceBinder - - companion object { - val instance: FirebaseSessionsFakeComponent - get() = Firebase.app[FirebaseSessionsFakeComponent::class.java] - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt deleted file mode 100644 index 8dc6454931e..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.sessions.testing - -import com.google.firebase.components.Component -import com.google.firebase.components.ComponentRegistrar -import com.google.firebase.components.Dependency -import com.google.firebase.components.Qualified.unqualified -import com.google.firebase.platforminfo.LibraryVersionComponent -import com.google.firebase.sessions.BuildConfig -import com.google.firebase.sessions.FirebaseSessions -import com.google.firebase.sessions.FirebaseSessionsComponent - -/** - * [ComponentRegistrar] for setting up Fake components for [FirebaseSessions] and its internal - * dependencies for unit tests. - */ -internal class FirebaseSessionsFakeRegistrar : ComponentRegistrar { - override fun getComponents() = - listOf( - Component.builder(FirebaseSessionsComponent::class.java) - .name("fire-sessions-component") - .add(Dependency.required(firebaseSessionsFakeComponent)) - .factory { container -> container.get(firebaseSessionsFakeComponent) } - .build(), - Component.builder(FirebaseSessionsFakeComponent::class.java) - .name("fire-sessions-fake-component") - .factory { FirebaseSessionsFakeComponent() } - .build(), - LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME), - ) - - private companion object { - const val LIBRARY_NAME = "fire-sessions" - - val firebaseSessionsFakeComponent = unqualified(FirebaseSessionsFakeComponent::class.java) - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt index 7619bc12588..105950a37f4 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestSessionEventData.kt @@ -30,23 +30,24 @@ import com.google.firebase.sessions.ProcessDetails import com.google.firebase.sessions.SessionDetails import com.google.firebase.sessions.SessionEvent import com.google.firebase.sessions.SessionInfo +import com.google.firebase.sessions.Time internal object TestSessionEventData { - const val TEST_SESSION_TIMESTAMP_US: Long = 12340000 + val TEST_SESSION_TIMESTAMP: Time = Time(ms = 12340) val TEST_SESSION_DETAILS = SessionDetails( sessionId = "a1b2c3", firstSessionId = "a1a1a1", sessionIndex = 3, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) val TEST_DATA_COLLECTION_STATUS = DataCollectionStatus( performance = DataCollectionState.COLLECTION_SDK_NOT_INSTALLED, crashlytics = DataCollectionState.COLLECTION_SDK_NOT_INSTALLED, - sessionSamplingRate = 1.0 + sessionSamplingRate = 1.0, ) val TEST_SESSION_DATA = @@ -54,19 +55,14 @@ internal object TestSessionEventData { sessionId = "a1b2c3", firstSessionId = "a1a1a1", sessionIndex = 3, - eventTimestampUs = TEST_SESSION_TIMESTAMP_US, + eventTimestampUs = TEST_SESSION_TIMESTAMP.us, dataCollectionStatus = TEST_DATA_COLLECTION_STATUS, firebaseInstallationId = "", firebaseAuthenticationToken = "", ) val TEST_PROCESS_DETAILS = - ProcessDetails( - processName = "com.google.firebase.sessions.test", - 0, - 100, - false, - ) + ProcessDetails(processName = "com.google.firebase.sessions.test", 0, 100, false) val TEST_APP_PROCESS_DETAILS = listOf(TEST_PROCESS_DETAILS) diff --git a/firebase-sessions/test-app/src/main/AndroidManifest.xml b/firebase-sessions/test-app/src/main/AndroidManifest.xml index 9965842a01e..8d2011ca18e 100644 --- a/firebase-sessions/test-app/src/main/AndroidManifest.xml +++ b/firebase-sessions/test-app/src/main/AndroidManifest.xml @@ -45,8 +45,8 @@ + android:name="sessions_sampling_percentage" + android:value="0.01" /> + + + + = Build.VERSION_CODES.P) Application.getProcessName() else "unknown" - private fun logProcessDetails() { val pid = android.os.Process.myPid() val uid = android.os.Process.myUid() val activity = javaClass.name - val process = getProcessName() - Log.i(TAG, "activity: $activity process: $process, pid: $pid, uid: $uid") + Log.i(TAG, "activity: $activity process: $myProcessName, pid: $pid, uid: $uid") } private fun logFirebaseDetails() { @@ -85,15 +80,11 @@ open class BaseActivity : AppCompatActivity() { val defaultFirebaseApp = FirebaseApp.getInstance() Log.i( TAG, - "activity: $activity firebase: ${defaultFirebaseApp.name} appsCount: ${firebaseApps.count()}" + "activity: $activity firebase: ${defaultFirebaseApp.name} appsCount: ${firebaseApps.count()}", ) } private fun setProcessAttribute() { - FirebasePerformance.getInstance().putAttribute("process_name", getProcessName()) - } - - companion object { - val TAG = "BaseActivity" + FirebasePerformance.getInstance().putAttribute("process_name", myProcessName) } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt index 89d2f03f1ce..203ab0416d1 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/CrashBroadcastReceiver.kt @@ -21,6 +21,7 @@ import android.content.Context import android.content.Intent import android.util.Log import android.widget.Toast +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG class CrashBroadcastReceiver : BroadcastReceiver() { @@ -42,7 +43,6 @@ class CrashBroadcastReceiver : BroadcastReceiver() { } companion object { - val TAG = "CrashBroadcastReceiver" val CRASH_ACTION = "com.google.firebase.testing.sessions.CrashBroadcastReceiver.CRASH_ACTION" val TOAST_ACTION = "com.google.firebase.testing.sessions.CrashBroadcastReceiver.TOAST_ACTION" } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt index 6f3704c0501..f5502b86db9 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt @@ -16,7 +16,6 @@ package com.google.firebase.testing.sessions -import android.app.Application import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT import android.content.Intent.FLAG_ACTIVITY_NEW_TASK @@ -30,7 +29,11 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.perf.FirebasePerformance +import com.google.firebase.perf.trace +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName import com.google.firebase.testing.sessions.databinding.FragmentFirstBinding +import java.net.HttpURLConnection +import java.net.URL import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers @@ -52,7 +55,7 @@ class FirstFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View? { _binding = FragmentFirstBinding.inflate(inflater, container, false) @@ -79,6 +82,28 @@ class FirstFragment : Fragment() { performanceTrace.stop() } } + binding.createTrace2.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val performanceTrace = performance.newTrace("test_trace_2") + performanceTrace.start() + delay(1200) + performanceTrace.stop() + } + } + binding.createNetworkTrace.setOnClickListener { + lifecycleScope.launch(Dispatchers.IO) { + val url = URL("https://www.google.com") + val metric = + performance.newHttpMetric("https://www.google.com", FirebasePerformance.HttpMethod.GET) + metric.trace { + val conn = url.openConnection() as HttpURLConnection + val content = conn.inputStream.bufferedReader().use { it.readText() } + setHttpResponseCode(conn.responseCode) + setResponsePayloadSize(content.length.toLong()) + conn.disconnect() + } + } + } binding.buttonForegroundProcess.setOnClickListener { if (binding.buttonForegroundProcess.getText().startsWith("Start")) { ForegroundService.startService(requireContext(), "Starting service at ${getDateText()}") @@ -103,7 +128,7 @@ class FirstFragment : Fragment() { intent.addFlags(FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } - binding.processName.text = getProcessName() + binding.processName.text = myProcessName } override fun onResume() { @@ -126,9 +151,5 @@ class FirstFragment : Fragment() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) else "unknown" - - fun getProcessName(): String = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Application.getProcessName() - else "unknown" } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt index f616a0a54a4..a17511c4740 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/ForegroundService.kt @@ -29,6 +29,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import com.google.firebase.FirebaseApp +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG class ForegroundService : Service() { private val CHANNEL_ID = "CrashForegroundService" @@ -104,10 +105,8 @@ class ForegroundService : Service() { } companion object { - val TAG = "WidgetForegroundService" - fun startService(context: Context, message: String) { - Log.i(TAG, "Starting foreground serice") + Log.i(TAG, "Starting foreground service") ContextCompat.startForegroundService( context, Intent(context, ForegroundService::class.java).putExtra("inputExtra", message), @@ -115,7 +114,7 @@ class ForegroundService : Service() { } fun stopService(context: Context) { - Log.i(TAG, "Stopping serice") + Log.i(TAG, "Stopping service") context.stopService(Intent(context, ForegroundService::class.java)) } } diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceA.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceA.kt new file mode 100644 index 00000000000..499a91013f3 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceA.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.sessions + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName +import kotlin.system.exitProcess + +class MyServiceA : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Service A action: ${intent?.action} on process: $myProcessName") + + // Send actions from adb shell this way, so it can start the process if needed: + // am startservice --user 0 -n com.google.firebase.testing.sessions/.MyServiceA -a PING + when (intent?.action) { + "PING" -> ping() + "CRASH" -> crash() + "KILL" -> kill() + "SESSION" -> session() + } + + return START_STICKY + } + + private fun ping() { + repeat(7) { Log.i(TAG, "*** pong ***") } + } + + private fun crash() { + Log.i(TAG, "crashing") + throw IndexOutOfBoundsException("crash service a") + } + + private fun kill() { + Log.i(TAG, "killing process $myProcessName") + exitProcess(0) + } + + private fun session() { + Log.i( + TAG, + "service a, session id: ${TestApplication.sessionSubscriber.sessionDetails?.sessionId}", + ) + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceB.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceB.kt new file mode 100644 index 00000000000..cb9791796b0 --- /dev/null +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/MyServiceB.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.testing.sessions + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log +import com.google.firebase.testing.sessions.TestApplication.Companion.TAG +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName +import kotlin.system.exitProcess + +class MyServiceB : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Service B action: ${intent?.action} on process: $myProcessName") + + when (intent?.action) { + "PING" -> ping() + "CRASH" -> crash() + "KILL" -> kill() + } + + return START_STICKY + } + + private fun ping() { + repeat(7) { Log.i(TAG, "*** hello ***") } + Log.i(TAG, "session id: ${TestApplication.sessionSubscriber.sessionDetails?.sessionId}") + } + + private fun crash() { + Log.i(TAG, "crashing") + throw IllegalStateException("crash in service b") + } + + private fun kill() { + Log.i(TAG, "killing process $myProcessName") + exitProcess(0) + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt index 6c2fd3c06b0..434ff1dec08 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/SecondActivity.kt @@ -25,6 +25,7 @@ import android.widget.Button import android.widget.TextView import androidx.lifecycle.lifecycleScope import com.google.firebase.perf.FirebasePerformance +import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -56,7 +57,7 @@ class SecondActivity : BaseActivity() { .killBackgroundProcesses("com.google.firebase.testing.sessions") } } - findViewById(R.id.process_name_second).text = getProcessName() + findViewById(R.id.process_name_second).text = myProcessName } override fun onResume() { diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt index 10a95261fa8..f8b8dec2cc7 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/TestApplication.kt @@ -17,21 +17,29 @@ package com.google.firebase.testing.sessions import android.annotation.SuppressLint +import android.app.Application import android.content.IntentFilter import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Log import android.widget.TextView import androidx.multidex.MultiDexApplication +import com.google.firebase.FirebaseApp import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.api.SessionSubscriber +import java.io.File class TestApplication : MultiDexApplication() { private val broadcastReceiver = CrashBroadcastReceiver() + @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.i(TAG, "TestApplication created on process: $myProcessName") + FirebaseApp.initializeApp(this) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver( broadcastReceiver, IntentFilter(CrashBroadcastReceiver.CRASH_ACTION), @@ -48,6 +56,7 @@ class TestApplication : MultiDexApplication() { } } + @SuppressLint("DiscouragedApi") class FakeSessionSubscriber : SessionSubscriber { override val isDataCollectionEnabled = true override val sessionSubscriberName = SessionSubscriber.Name.MATT_SAYS_HI @@ -78,8 +87,19 @@ class TestApplication : MultiDexApplication() { @SuppressLint("DiscouragedApi") companion object { + const val TAG = "SessionsTestApp" + val sessionSubscriber = FakeSessionSubscriber() + val myProcessName: String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) Application.getProcessName() + else + try { + File("/proc/self/cmdline").readText().substringBefore('\u0000').trim() + } catch (_: Exception) { + null + } ?: "unknown" + init { FirebaseSessionsDependencies.addDependency(SessionSubscriber.Name.MATT_SAYS_HI) FirebaseSessionsDependencies.register(sessionSubscriber) diff --git a/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml b/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml index af08e7e317e..b40bee65a09 100644 --- a/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml +++ b/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml @@ -55,13 +55,30 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@id/button_anr" /> +