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..c74b6e4e329 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() @@ -69,5 +50,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/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt index b715cd9f79c..1079577e03c 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 @@ -23,13 +23,11 @@ import com.google.firebase.installations.FirebaseInstallationsApi import com.google.firebase.sessions.ApplicationInfo import com.google.firebase.sessions.InstallationId import com.google.firebase.sessions.TimeProvider -import dagger.Lazy import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONException @@ -43,11 +41,8 @@ constructor( 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? @@ -133,7 +128,7 @@ constructor( sessionTimeoutSeconds = sessionTimeoutSeconds, sessionSamplingRate = sessionSamplingRate, cacheDurationSeconds = cacheDuration ?: defaultCacheDuration, - cacheUpdatedTimeMs = timeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = timeProvider.currentTime().seconds, ) ) }, @@ -148,7 +143,7 @@ constructor( override fun isSettingsStale(): Boolean = settingsCache.hasCacheExpired() @VisibleForTesting - internal fun clearCachedSettings() = runBlocking { + internal suspend fun clearCachedSettings() { settingsCache.updateConfigs(SessionConfigsSerializer.defaultValue) } 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 index 8d7e2484675..ab310ebed8a 100644 --- 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 @@ -30,7 +30,7 @@ internal data class SessionConfigs( val sessionSamplingRate: Double?, val sessionTimeoutSeconds: Int?, val cacheDurationSeconds: Int?, - val cacheUpdatedTimeMs: Long?, + val cacheUpdatedTimeSeconds: Long?, ) /** DataStore json [Serializer] for [SessionConfigs]. */ @@ -41,7 +41,7 @@ internal object SessionConfigsSerializer : Serializer { sessionSamplingRate = null, sessionTimeoutSeconds = null, cacheDurationSeconds = null, - cacheUpdatedTimeMs = null, + cacheUpdatedTimeSeconds = null, ) override suspend fun readFrom(input: InputStream): SessionConfigs = 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 468bbad6b7a..1640a5c7b7a 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 @@ -17,12 +17,18 @@ package com.google.firebase.sessions.settings import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.datastore.core.DataStore +import com.google.firebase.annotations.concurrent.Background 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 interface SettingsCache { @@ -41,23 +47,38 @@ internal interface SettingsCache { internal class SettingsCacheImpl @Inject constructor( + @Background private val backgroundDispatcher: CoroutineContext, private val timeProvider: TimeProvider, private val sessionConfigsDataStore: DataStore, ) : SettingsCache { - private var sessionConfigs: SessionConfigs + 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() }, + ) + } + + return sessionConfigsAtomicReference.get() + } init { - // Block until the cache is loaded from disk to ensure cache - // values are valid and readable from the main thread on init. - runBlocking { sessionConfigs = sessionConfigsDataStore.data.first() } + CoroutineScope(backgroundDispatcher).launch { + sessionConfigsDataStore.data.collect(sessionConfigsAtomicReference::set) + } } override fun hasCacheExpired(): Boolean { - val cacheUpdatedTimeMs = sessionConfigs.cacheUpdatedTimeMs + val cacheUpdatedTimeSeconds = sessionConfigs.cacheUpdatedTimeSeconds val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds - if (cacheUpdatedTimeMs != null && cacheDurationSeconds != null) { - val timeDifferenceSeconds = (timeProvider.currentTime().ms - cacheUpdatedTimeMs) / 1000 + if (cacheUpdatedTimeSeconds != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = timeProvider.currentTime().seconds - cacheUpdatedTimeSeconds if (timeDifferenceSeconds < cacheDurationSeconds) { return false } @@ -74,12 +95,12 @@ constructor( override suspend fun updateConfigs(sessionConfigs: SessionConfigs) { try { sessionConfigsDataStore.updateData { sessionConfigs } - this.sessionConfigs = sessionConfigs } catch (ex: IOException) { Log.w(TAG, "Failed to update config values: $ex") } } + @VisibleForTesting internal suspend fun removeConfigs() = try { sessionConfigsDataStore.updateData { SessionConfigsSerializer.defaultValue } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt index 7e94eb3113e..efe7bb27a97 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt @@ -44,7 +44,7 @@ class SessionDatastoreTest { DataStoreFactory.create( serializer = SessionDataSerializer, scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), - produceFile = { appContext.dataStoreFile("sessionDataTestDataStore.data") }, + produceFile = { appContext.dataStoreFile("sessionDataStore.data") }, ), ) 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 ccaf4f8954d..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 @@ -19,10 +19,7 @@ package com.google.firebase.sessions.settings import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.installations.FirebaseInstallationsApi -import com.google.firebase.sessions.ApplicationInfo import com.google.firebase.sessions.SessionEvents -import com.google.firebase.sessions.TimeProvider import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher @@ -31,11 +28,8 @@ 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.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 @@ -43,43 +37,37 @@ 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 firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - - val remoteSettings = - buildRemoteSettings( - FakeTimeProvider(), - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - FakeSettingsCache(), - ) - - runCurrent() + fun remoteSettings_successfulFetchCachesValues() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() - assertThat(remoteSettings.sessionEnabled).isNull() - assertThat(remoteSettings.samplingRate).isNull() - assertThat(remoteSettings.sessionRestartTimeout).isNull() + val remoteSettings = + RemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) - fakeFetcher.responseJSONObject = JSONObject(VALID_RESPONSE) - remoteSettings.updateSettings() + assertThat(remoteSettings.sessionEnabled).isNull() + assertThat(remoteSettings.samplingRate).isNull() + assertThat(remoteSettings.sessionRestartTimeout).isNull() - runCurrent() + fakeFetcher.responseJSONObject = JSONObject(VALID_RESPONSE) + remoteSettings.updateSettings() - assertThat(remoteSettings.sessionEnabled).isFalse() - assertThat(remoteSettings.samplingRate).isEqualTo(0.75) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) + assertThat(remoteSettings.sessionEnabled).isFalse() + assertThat(remoteSettings.samplingRate).isEqualTo(0.75) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) - remoteSettings.clearCachedSettings() - } + remoteSettings.clearCachedSettings() + } @Test fun remoteSettings_successfulFetchWithLessConfigsCachesOnlyReceivedValues() = runTest { @@ -88,7 +76,7 @@ class RemoteSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher() val remoteSettings = - buildRemoteSettings( + RemoteSettings( FakeTimeProvider(), firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), @@ -96,8 +84,6 @@ class RemoteSettingsTest { FakeSettingsCache(), ) - runCurrent() - assertThat(remoteSettings.sessionEnabled).isNull() assertThat(remoteSettings.samplingRate).isNull() assertThat(remoteSettings.sessionRestartTimeout).isNull() @@ -107,8 +93,6 @@ class RemoteSettingsTest { fakeFetcher.responseJSONObject = fetchedResponse remoteSettings.updateSettings() - runCurrent() - assertThat(remoteSettings.sessionEnabled).isNull() assertThat(remoteSettings.samplingRate).isEqualTo(0.75) assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) @@ -124,7 +108,7 @@ class RemoteSettingsTest { val fakeTimeProvider = FakeTimeProvider() val remoteSettings = - buildRemoteSettings( + RemoteSettings( fakeTimeProvider, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), @@ -137,8 +121,6 @@ class RemoteSettingsTest { fakeFetcher.responseJSONObject = fetchedResponse remoteSettings.updateSettings() - runCurrent() - assertThat(remoteSettings.sessionEnabled).isFalse() assertThat(remoteSettings.samplingRate).isEqualTo(0.75) assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(40.minutes) @@ -152,8 +134,6 @@ class RemoteSettingsTest { fakeFetcher.responseJSONObject = fetchedResponse remoteSettings.updateSettings() - runCurrent() - assertThat(remoteSettings.sessionEnabled).isTrue() assertThat(remoteSettings.samplingRate).isEqualTo(0.25) assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) @@ -162,110 +142,99 @@ class RemoteSettingsTest { } @Test - fun remoteSettings_successfulFetchWithEmptyConfigRetainsOldConfigs() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() - val fakeTimeProvider = FakeTimeProvider() - - val remoteSettings = - buildRemoteSettings( - 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() - - runCurrent() - fakeTimeProvider.addInterval(31.seconds) - - 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() - Thread.sleep(30) - - 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 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( - 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() - } + 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(), + ) - // 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() } + // Do the first fetch. This one should fetched the configsFetcher. + val firstFetch = launch(Dispatchers.Default) { remoteSettingsWithDelay.updateSettings() } - // Assert that the configsFetcher was fetched exactly once. - assertThat(fakeFetcherWithDelay.timesCalled).isEqualTo(1) - assertThat(remoteSettingsWithDelay.samplingRate).isEqualTo(0.125) + // 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() } + + // 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 { + private companion object { const val VALID_RESPONSE = """ { @@ -284,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( - timeProvider: TimeProvider, - firebaseInstallationsApi: FirebaseInstallationsApi, - appInfo: ApplicationInfo, - configsFetcher: CrashlyticsSettingsFetcher, - settingsCache: SettingsCache, - ): RemoteSettings = - RemoteSettings_Factory.create( - { timeProvider }, - { 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 f87d773b970..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 @@ -20,7 +20,6 @@ import android.os.Bundle import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp import com.google.firebase.sessions.SessionEvents -import com.google.firebase.sessions.settings.RemoteSettingsTest.Companion.buildRemoteSettings import com.google.firebase.sessions.testing.FakeFirebaseApp import com.google.firebase.sessions.testing.FakeFirebaseInstallations import com.google.firebase.sessions.testing.FakeRemoteConfigFetcher @@ -28,9 +27,6 @@ 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.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.After @@ -38,7 +34,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SessionsSettingsTest { @@ -89,128 +84,117 @@ 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 = - buildRemoteSettings( - FakeTimeProvider(), - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - FakeSettingsCache(), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) - - sessionsSettings.updateSettings() - - runCurrent() - - assertThat(sessionsSettings.sessionsEnabled).isFalse() - assertThat(sessionsSettings.samplingRate).isEqualTo(0.75) - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) - - remoteSettings.clearCachedSettings() - } + 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(), + ) + + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) + + sessionsSettings.updateSettings() + + assertThat(sessionsSettings.sessionsEnabled).isFalse() + assertThat(sessionsSettings.samplingRate).isEqualTo(0.75) + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) + + 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 = - buildRemoteSettings( - FakeTimeProvider(), - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - FakeSettingsCache(), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) - - sessionsSettings.updateSettings() - - runCurrent() - - assertThat(sessionsSettings.sessionsEnabled).isTrue() - assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(3.minutes) - - remoteSettings.clearCachedSettings() - } + 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(), + ) + + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) + + sessionsSettings.updateSettings() + + assertThat(sessionsSettings.sessionsEnabled).isTrue() + assertThat(sessionsSettings.samplingRate).isEqualTo(0.5) + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(3.minutes) + + 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 = - buildRemoteSettings( - FakeTimeProvider(), - firebaseInstallations, - SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcher, - FakeSettingsCache(), - ) - - val sessionsSettings = - SessionsSettings( - localOverrideSettings = LocalOverrideSettings(context), - remoteSettings = remoteSettings, - ) - - sessionsSettings.updateSettings() - - runCurrent() - - assertThat(sessionsSettings.sessionsEnabled).isFalse() // Manifest - assertThat(sessionsSettings.samplingRate).isEqualTo(1.0) // SDK default - assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) // Remote - - remoteSettings.clearCachedSettings() - } + 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(), + ) + + val sessionsSettings = + SessionsSettings( + localOverrideSettings = LocalOverrideSettings(context), + remoteSettings = remoteSettings, + ) + + sessionsSettings.updateSettings() + + assertThat(sessionsSettings.sessionsEnabled).isFalse() // Manifest + assertThat(sessionsSettings.samplingRate).isEqualTo(1.0) // SDK default + assertThat(sessionsSettings.sessionRestartTimeout).isEqualTo(40.minutes) // Remote + + remoteSettings.clearCachedSettings() + } @After fun cleanUp() { 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 729208c33ca..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 @@ -16,23 +16,44 @@ package com.google.firebase.sessions.settings +import android.content.Context +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.FakeTimeProvider -import com.google.firebase.sessions.testing.TestDataStores +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 import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class SettingsCacheTest { + private val appContext: Context = ApplicationProvider.getApplicationContext() @Test fun sessionCache_returnsEmptyCache() = runTest { val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + 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() @@ -43,14 +64,24 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCachedValue() = runTest { val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + 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, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 1000, ) ) @@ -66,22 +97,40 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsPreviouslyStoredValue() = runTest { + val sessionConfigsDataStore = + DataStoreFactory.create( + serializer = SessionConfigsSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionConfigsDataStore.data") }, + ) + val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + val settingsCache = + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = sessionConfigsDataStore, + ) settingsCache.updateConfigs( SessionConfigs( sessionsEnabled = false, sessionSamplingRate = 0.25, sessionTimeoutSeconds = 600, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 1000, ) ) // Create a new instance to imitate a second app launch. val newSettingsCache = - SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + SettingsCacheImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + timeProvider = fakeTimeProvider, + sessionConfigsDataStore = sessionConfigsDataStore, + ) + + runCurrent() assertThat(newSettingsCache.sessionsEnabled()).isFalse() assertThat(newSettingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -96,14 +145,24 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCacheExpiredWithShortCacheDuration() = runTest { val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + 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, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 0, ) ) @@ -119,14 +178,24 @@ class SettingsCacheTest { @Test fun settingConfigsReturnsCachedValueWithPartialConfigs() = runTest { val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + 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, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 1000, ) ) @@ -142,14 +211,24 @@ class SettingsCacheTest { @Test fun settingConfigsAllowsUpdateConfigsAndCachesValues() = runTest { val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + 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, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 1000, ) ) @@ -164,7 +243,7 @@ class SettingsCacheTest { sessionsEnabled = true, sessionSamplingRate = 0.33, sessionTimeoutSeconds = 100, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 0, ) ) @@ -180,14 +259,24 @@ class SettingsCacheTest { @Test fun settingConfigsCleansCacheForNullValues() = runTest { val fakeTimeProvider = FakeTimeProvider() - val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + 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, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 1000, ) ) @@ -202,7 +291,7 @@ class SettingsCacheTest { sessionsEnabled = null, sessionSamplingRate = 0.33, sessionTimeoutSeconds = null, - cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheUpdatedTimeSeconds = fakeTimeProvider.currentTime().seconds, cacheDurationSeconds = 1000, ) ) 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 index 2a3e28c00b9..2c58ef22d7d 100644 --- 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 @@ -27,11 +27,11 @@ internal class FakeSettingsCache( private var sessionConfigs: SessionConfigs = SessionConfigsSerializer.defaultValue, ) : SettingsCache { override fun hasCacheExpired(): Boolean { - val cacheUpdatedTimeMs = sessionConfigs.cacheUpdatedTimeMs + val cacheUpdatedTimeSeconds = sessionConfigs.cacheUpdatedTimeSeconds val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds - if (cacheUpdatedTimeMs != null && cacheDurationSeconds != null) { - val timeDifferenceSeconds = (timeProvider.currentTime().ms - cacheUpdatedTimeMs) / 1000 + if (cacheUpdatedTimeSeconds != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = timeProvider.currentTime().seconds - cacheUpdatedTimeSeconds if (timeDifferenceSeconds < cacheDurationSeconds) { return false } diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt deleted file mode 100644 index d7cc3a7f67d..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt +++ /dev/null @@ -1,50 +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 android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile -import androidx.test.core.app.ApplicationProvider -import com.google.firebase.sessions.SessionData -import com.google.firebase.sessions.SessionDataSerializer -import com.google.firebase.sessions.settings.SessionConfigs -import com.google.firebase.sessions.settings.SessionConfigsSerializer - -/** - * Container of instances of [DataStore] for testing. - * - * Note these do not pass the test scheduler to the instances, so won't work with `runCurrent`. - */ -internal object TestDataStores { - private val appContext: Context = ApplicationProvider.getApplicationContext() - - val sessionConfigsDataStore: DataStore by lazy { - DataStoreFactory.create( - serializer = SessionConfigsSerializer, - produceFile = { appContext.dataStoreFile("sessionConfigsTestDataStore.data") }, - ) - } - - val sessionDataStore: DataStore by lazy { - DataStoreFactory.create( - serializer = SessionDataSerializer, - produceFile = { appContext.dataStoreFile("sessionDataTestDataStore.data") }, - ) - } -}