From 5fdcaeee0443ecfc13416d0cac61a20f1d4b14ce Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Thu, 20 Mar 2025 10:28:52 -0600 Subject: [PATCH 01/23] Use multi-process DataStore instead of Preferences DataStore (#6781) Use multi-process DataStore instead of Preferences DataStore. This change allows multiple processes to share the same datastore file safely. This reduces settings fetch to one per app run, not one per process. Also updated the TimeProvider to provide an object with explicit time units. This will make time less error prone. Removed all instances of `System.currentTimeMillis()` from tests, making them deterministic. --- firebase-sessions/CHANGELOG.md | 14 +- .../firebase-sessions.gradle.kts | 4 +- .../sessions/FirebaseSessionsComponent.kt | 60 +++--- .../sessions/FirebaseSessionsRegistrar.kt | 4 +- .../sessions/SessionDataStoreConfigs.kt | 40 ---- .../firebase/sessions/SessionDatastore.kt | 65 +++--- .../firebase/sessions/SessionGenerator.kt | 2 +- .../google/firebase/sessions/TimeProvider.kt | 15 +- .../sessions/settings/RemoteSettings.kt | 52 ++--- .../settings/RemoteSettingsFetcher.kt | 4 +- .../sessions/settings/SessionConfigs.kt | 58 ++++++ .../sessions/settings/SettingsCache.kt | 123 ++++------- .../firebase/sessions/SessionDatastoreTest.kt | 59 ++++++ .../firebase/sessions/SessionGeneratorTest.kt | 13 +- .../sessions/settings/RemoteSettingsTest.kt | 196 ++++++++---------- .../sessions/settings/SessionsSettingsTest.kt | 48 ++--- .../sessions/settings/SettingsCacheTest.kt | 169 ++++++++------- .../sessions/testing/FakeSettingsCache.kt | 52 +++++ .../sessions/testing/FakeTimeProvider.kt | 10 +- .../sessions/testing/TestDataStores.kt | 50 +++++ .../sessions/testing/TestSessionEventData.kt | 16 +- 21 files changed, 574 insertions(+), 480 deletions(-) delete mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDataStoreConfigs.kt create mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/SessionConfigs.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt diff --git a/firebase-sessions/CHANGELOG.md b/firebase-sessions/CHANGELOG.md index 70ad76eb6fe..55e15d56850 100644 --- a/firebase-sessions/CHANGELOG.md +++ b/firebase-sessions/CHANGELOG.md @@ -1,5 +1,5 @@ # Unreleased - +* [changed] Use multi-process DataStore instead of Preferences DataStore # 2.1.1 * [unchanged] Updated to keep SDK versions aligned. @@ -16,21 +16,9 @@ updates. * [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/firebase-sessions.gradle.kts b/firebase-sessions/firebase-sessions.gradle.kts index b136a281660..23edc952d5e 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 { @@ -76,7 +77,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/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt index 5680c9cc0ec..99de9e4a3fc 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 @@ -19,23 +19,24 @@ package com.google.firebase.sessions import android.content.Context import android.util.Log import androidx.datastore.core.DataStore +import androidx.datastore.core.MultiProcessDataStoreFactory 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.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 @@ -45,10 +46,7 @@ import dagger.Provides 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 @@ -119,6 +117,8 @@ internal interface FirebaseSessionsComponent { @RemoteSettingsProvider fun remoteSettings(impl: RemoteSettings): SettingsProvider + @Binds @Singleton fun settingsCache(impl: SettingsCacheImpl): SettingsCache + companion object { private const val TAG = "FirebaseSessions" @@ -133,31 +133,37 @@ internal interface FirebaseSessionsComponent { @Provides @Singleton - @SessionConfigsDataStore - fun sessionConfigsDataStore(appContext: Context): DataStore = - PreferenceDataStoreFactory.create( + fun sessionConfigsDataStore( + appContext: Context, + @Blocking blockingDispatcher: CoroutineContext, + ): DataStore = + MultiProcessDataStoreFactory.create( + 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, + ): DataStore = + MultiProcessDataStoreFactory.create( + 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") }, + ) } } } 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..76c0c6330f4 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 @@ -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/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 index 2c4f243f942..b3b72b4d4d7 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt @@ -17,15 +17,15 @@ package com.google.firebase.sessions import android.util.Log +import androidx.datastore.core.CorruptionException import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.core.Serializer import com.google.firebase.Firebase import com.google.firebase.annotations.concurrent.Background import com.google.firebase.app import java.io.IOException +import java.io.InputStream +import java.io.OutputStream import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton @@ -33,11 +33,29 @@ 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 +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json -/** Datastore for sessions information */ -internal data class FirebaseSessionsData(val sessionId: String?) +/** Data for sessions information */ +@Serializable internal data class SessionData(val sessionId: String?) + +/** DataStore json [Serializer] for [SessionData]. */ +internal object SessionDataSerializer : Serializer { + override val defaultValue = SessionData(sessionId = 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()) + } +} /** Handles reading to and writing from the [DataStore]. */ internal interface SessionDatastore { @@ -61,23 +79,17 @@ internal class SessionDatastoreImpl @Inject constructor( @Background private val backgroundDispatcher: CoroutineContext, - @SessionDetailsDataStore private val dataStore: DataStore, + private val sessionDataStore: DataStore, ) : SessionDatastore { /** Most recent session from datastore is updated asynchronously whenever it changes */ - private val currentSessionFromDatastore = AtomicReference() + 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) } + private val firebaseSessionDataFlow: Flow = + sessionDataStore.data.catch { ex -> + Log.e(TAG, "Error reading stored session data.", ex) + emit(SessionDataSerializer.defaultValue) + } init { CoroutineScope(backgroundDispatcher).launch { @@ -88,19 +100,14 @@ constructor( 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") + sessionDataStore.updateData { SessionData(sessionId) } + } catch (ex: IOException) { + Log.w(TAG, "Failed to update session Id", ex) } } } - override fun getCurrentSessionId() = currentSessionFromDatastore.get()?.sessionId - - private fun mapSessionsData(preferences: Preferences): FirebaseSessionsData = - FirebaseSessionsData(preferences[FirebaseSessionDataKeys.SESSION_ID]) + override fun getCurrentSessionId(): String? = currentSessionFromDatastore.get()?.sessionId private companion object { private const val TAG = "FirebaseSessionsRepo" 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..409f9989348 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 @@ -60,7 +60,7 @@ constructor(private val timeProvider: TimeProvider, private val uuidGenerator: U sessionId = if (sessionIndex == 0) firstSessionId else generateSessionId(), firstSessionId, sessionIndex, - sessionStartTimestampUs = timeProvider.currentTimeUs(), + sessionStartTimestampUs = timeProvider.currentTime().us, ) return currentSession } 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..869b64b2ff2 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 @@ -20,11 +20,17 @@ import android.os.SystemClock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +/** Time with accessors for microseconds, milliseconds, and seconds. */ +internal data class Time(val ms: Long) { + val us = ms * 1_000 + val seconds = ms / 1_000 +} + /** 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 +44,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/settings/RemoteSettings.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/settings/RemoteSettings.kt index 67a48bc7924..b715cd9f79c 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,17 @@ 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.InstallationId +import com.google.firebase.sessions.TimeProvider import dagger.Lazy 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.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONException @@ -40,7 +39,7 @@ 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, @@ -90,10 +89,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 +127,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, + cacheUpdatedTimeMs = timeProvider.currentTime().ms, + ) + ) }, 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 +148,17 @@ constructor( override fun isSettingsStale(): Boolean = settingsCache.hasCacheExpired() @VisibleForTesting - internal fun clearCachedSettings() { - val scope = CoroutineScope(backgroundDispatcher) - scope.launch { settingsCache.removeConfigs() } + internal fun clearCachedSettings() = runBlocking { + 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" - const val FORWARD_SLASH_STRING: String = "/" + val defaultCacheDuration = 24.hours.inWholeSeconds.toInt() + + 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..8d7e2484675 --- /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 cacheUpdatedTimeMs: Long?, +) + +/** DataStore json [Serializer] for [SessionConfigs]. */ +internal object SessionConfigsSerializer : Serializer { + override val defaultValue = + SessionConfigs( + sessionsEnabled = null, + sessionSamplingRate = null, + sessionTimeoutSeconds = null, + cacheDurationSeconds = null, + cacheUpdatedTimeMs = 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..468bbad6b7a 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,128 +17,77 @@ 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.sessions.TimeProvider import java.io.IOException import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.first 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( + private val timeProvider: TimeProvider, + private val sessionConfigsDataStore: DataStore, +) : SettingsCache { + private var sessionConfigs: SessionConfigs 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()) } + runBlocking { sessionConfigs = sessionConfigsDataStore.data.first() } } - /** 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], - ) - } + override fun hasCacheExpired(): Boolean { + val cacheUpdatedTimeMs = sessionConfigs.cacheUpdatedTimeMs + val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds - internal fun hasCacheExpired(): Boolean { - val cacheUpdatedTime = sessionConfigs.cacheUpdatedTime - val cacheDuration = sessionConfigs.cacheDuration - - if (cacheUpdatedTime != null && cacheDuration != null) { - val timeDifferenceSeconds = (System.currentTimeMillis() - cacheUpdatedTime) / 1000 - if (timeDifferenceSeconds < cacheDuration) { + if (cacheUpdatedTimeMs != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = (timeProvider.currentTime().ms - cacheUpdatedTimeMs) / 1000 + 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 } + this.sessionConfigs = 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. + 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/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt new file mode 100644 index 00000000000..7e94eb3113e --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.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 + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +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.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SessionDatastoreTest { + private val appContext: Context = ApplicationProvider.getApplicationContext() + + @Test + fun getCurrentSessionId_returnsLatest() = runTest { + val sessionDatastore = + SessionDatastoreImpl( + backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), + sessionDataStore = + DataStoreFactory.create( + serializer = SessionDataSerializer, + scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), + produceFile = { appContext.dataStoreFile("sessionDataTestDataStore.data") }, + ), + ) + + sessionDatastore.updateSessionId("sessionId1") + sessionDatastore.updateSessionId("sessionId2") + sessionDatastore.updateSessionId("sessionId3") + + runCurrent() + + assertThat(sessionDatastore.getCurrentSessionId()).isEqualTo("sessionId3") + } +} 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..bf260e73a4f 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) { @@ -96,7 +99,7 @@ class SessionGeneratorTest { sessionId = SESSION_ID_1, firstSessionId = SESSION_ID_1, sessionIndex = 0, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) } @@ -119,7 +122,7 @@ class SessionGeneratorTest { sessionId = SESSION_ID_1, firstSessionId = SESSION_ID_1, sessionIndex = 0, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) @@ -135,7 +138,7 @@ class SessionGeneratorTest { sessionId = SESSION_ID_2, firstSessionId = SESSION_ID_1, sessionIndex = 1, - sessionStartTimestampUs = TEST_SESSION_TIMESTAMP_US, + sessionStartTimestampUs = TEST_SESSION_TIMESTAMP.us, ) ) @@ -151,7 +154,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/settings/RemoteSettingsTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/settings/RemoteSettingsTest.kt index e4fb0b00148..ccaf4f8954d 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,24 +16,22 @@ 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.TimeProvider 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 @@ -53,22 +51,16 @@ class RemoteSettingsTest { 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, + FakeTimeProvider(), firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), + FakeSettingsCache(), ) runCurrent() @@ -90,120 +82,100 @@ class RemoteSettingsTest { } @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 = + buildRemoteSettings( + FakeTimeProvider(), + firebaseInstallations, + SessionEvents.getApplicationInfo(firebaseApp), + fakeFetcher, + FakeSettingsCache(), + ) + + 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() + } @Test - fun remoteSettings_successfulReFetchUpdatesCache() = - runTest(UnconfinedTestDispatcher()) { - val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext - val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") - val fakeFetcher = FakeRemoteConfigFetcher() + fun remoteSettings_successfulReFetchUpdatesCache() = runTest { + val firebaseApp = FakeFirebaseApp().firebaseApp + val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") + val fakeFetcher = FakeRemoteConfigFetcher() + val fakeTimeProvider = FakeTimeProvider() - 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 remoteSettings = + buildRemoteSettings( + 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() + val fetchedResponse = JSONObject(VALID_RESPONSE) + fetchedResponse.getJSONObject("app_quality").put("cache_duration", 1) + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() - runCurrent() + runCurrent() - 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) - 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) + 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) + fakeTimeProvider.addInterval(31.minutes) - fakeFetcher.responseJSONObject = fetchedResponse - remoteSettings.updateSettings() + fakeFetcher.responseJSONObject = fetchedResponse + remoteSettings.updateSettings() - runCurrent() + runCurrent() - assertThat(remoteSettings.sessionEnabled).isTrue() - assertThat(remoteSettings.samplingRate).isEqualTo(0.25) - assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) + assertThat(remoteSettings.sessionEnabled).isTrue() + assertThat(remoteSettings.samplingRate).isEqualTo(0.25) + assertThat(remoteSettings.sessionRestartTimeout).isEqualTo(20.minutes) - remoteSettings.clearCachedSettings() - } + remoteSettings.clearCachedSettings() + } @Test fun remoteSettings_successfulFetchWithEmptyConfigRetainsOldConfigs() = runTest(UnconfinedTestDispatcher()) { val firebaseApp = FakeFirebaseApp().firebaseApp - val context = firebaseApp.applicationContext val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD") val fakeFetcher = FakeRemoteConfigFetcher() + val fakeTimeProvider = FakeTimeProvider() val remoteSettings = buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + fakeTimeProvider, firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), + FakeSettingsCache(), ) val fetchedResponse = JSONObject(VALID_RESPONSE) @@ -212,6 +184,7 @@ class RemoteSettingsTest { remoteSettings.updateSettings() runCurrent() + fakeTimeProvider.addInterval(31.seconds) assertThat(remoteSettings.sessionEnabled).isFalse() assertThat(remoteSettings.samplingRate).isEqualTo(0.75) @@ -226,6 +199,7 @@ class RemoteSettingsTest { remoteSettings.updateSettings() runCurrent() + Thread.sleep(30) assertThat(remoteSettings.sessionEnabled).isFalse() assertThat(remoteSettings.samplingRate).isEqualTo(0.75) @@ -249,7 +223,6 @@ class RemoteSettingsTest { // - 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) @@ -260,16 +233,11 @@ class RemoteSettingsTest { val remoteSettingsWithDelay = buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + FakeTimeProvider(), firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), - fakeFetcherWithDelay, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), + configsFetcher = fakeFetcherWithDelay, + FakeSettingsCache(), ) // Do the first fetch. This one should fetched the configsFetcher. @@ -298,8 +266,6 @@ class RemoteSettingsTest { } internal companion object { - const val SESSION_TEST_CONFIGS_NAME = "firebase_session_settings_test" - const val VALID_RESPONSE = """ { @@ -329,14 +295,14 @@ class RemoteSettingsTest { * the test code. */ fun buildRemoteSettings( - backgroundDispatcher: CoroutineContext, + timeProvider: TimeProvider, firebaseInstallationsApi: FirebaseInstallationsApi, appInfo: ApplicationInfo, configsFetcher: CrashlyticsSettingsFetcher, settingsCache: SettingsCache, ): RemoteSettings = RemoteSettings_Factory.create( - { backgroundDispatcher }, + { timeProvider }, { firebaseInstallationsApi }, { appInfo }, { configsFetcher }, 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..f87d773b970 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,20 +17,18 @@ 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.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 +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 @@ -107,17 +105,12 @@ class SessionsSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + buildRemoteSettings( + FakeTimeProvider(), firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), + FakeSettingsCache(), ) val sessionsSettings = @@ -150,17 +143,12 @@ class SessionsSettingsTest { val fakeFetcher = FakeRemoteConfigFetcher(JSONObject(VALID_RESPONSE)) val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + buildRemoteSettings( + FakeTimeProvider(), firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), + FakeSettingsCache(), ) val sessionsSettings = @@ -199,17 +187,12 @@ class SessionsSettingsTest { fakeFetcher.responseJSONObject = JSONObject(invalidResponse) val remoteSettings = - RemoteSettingsTest.buildRemoteSettings( - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, + buildRemoteSettings( + FakeTimeProvider(), firebaseInstallations, SessionEvents.getApplicationInfo(firebaseApp), fakeFetcher, - SettingsCache( - PreferenceDataStoreFactory.create( - scope = this, - produceFile = { context.preferencesDataStoreFile(SESSION_TEST_CONFIGS_NAME) }, - ) - ), + FakeSettingsCache(), ) val sessionsSettings = @@ -229,19 +212,12 @@ class SessionsSettingsTest { remoteSettings.clearCachedSettings() } - @Test - fun sessionSettings_dataStorePreferencesNameIsFilenameSafe() { - assertThat(SessionDataStoreConfigs.SESSIONS_CONFIG_NAME).matches("^[a-zA-Z0-9_=]+\$") - } - @After fun cleanUp() { FirebaseApp.clearInstancesForTest() } 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..729208c33ca 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,30 +16,23 @@ 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 com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp -import com.google.firebase.sessions.testing.FakeFirebaseApp -import kotlinx.coroutines.ExperimentalCoroutinesApi +import com.google.firebase.sessions.testing.FakeTimeProvider +import com.google.firebase.sessions.testing.TestDataStores 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 Context.dataStore: DataStore by - preferencesDataStore(name = SESSION_TEST_CONFIGS_NAME) @Test fun sessionCache_returnsEmptyCache() = runTest { - val context = FakeFirebaseApp().firebaseApp.applicationContext - val settingsCache = SettingsCache(context.dataStore) + val fakeTimeProvider = FakeTimeProvider() + val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) assertThat(settingsCache.sessionSamplingRate()).isNull() assertThat(settingsCache.sessionsEnabled()).isNull() @@ -49,14 +42,18 @@ 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(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionsEnabled()).isFalse() assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -69,17 +66,22 @@ 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 fakeTimeProvider = FakeTimeProvider() + val settingsCache = SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheDurationSeconds = 1000, + ) + ) // Create a new instance to imitate a second app launch. - val newSettingsCache = SettingsCache(context.dataStore) + val newSettingsCache = + SettingsCacheImpl(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) assertThat(newSettingsCache.sessionsEnabled()).isFalse() assertThat(newSettingsCache.sessionSamplingRate()).isEqualTo(0.25) @@ -93,14 +95,18 @@ 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(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheDurationSeconds = 0, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() @@ -112,13 +118,18 @@ 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(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = null, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.25) assertThat(settingsCache.sessionsEnabled()).isFalse() @@ -130,25 +141,33 @@ 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(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + 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, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheDurationSeconds = 0, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.33) assertThat(settingsCache.sessionsEnabled()).isTrue() @@ -160,25 +179,33 @@ 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(fakeTimeProvider, TestDataStores.sessionConfigsDataStore) + + settingsCache.updateConfigs( + SessionConfigs( + sessionsEnabled = false, + sessionSamplingRate = 0.25, + sessionTimeoutSeconds = 600, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + 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, + cacheUpdatedTimeMs = fakeTimeProvider.currentTime().ms, + cacheDurationSeconds = 1000, + ) + ) assertThat(settingsCache.sessionSamplingRate()).isEqualTo(0.33) assertThat(settingsCache.sessionsEnabled()).isNull() @@ -192,8 +219,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/FakeSettingsCache.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSettingsCache.kt new file mode 100644 index 00000000000..2a3e28c00b9 --- /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 cacheUpdatedTimeMs = sessionConfigs.cacheUpdatedTimeMs + val cacheDurationSeconds = sessionConfigs.cacheDurationSeconds + + if (cacheUpdatedTimeMs != null && cacheDurationSeconds != null) { + val timeDifferenceSeconds = (timeProvider.currentTime().ms - cacheUpdatedTimeMs) / 1000 + 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/TestDataStores.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt new file mode 100644 index 00000000000..d7cc3a7f67d --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt @@ -0,0 +1,50 @@ +/* + * 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") }, + ) + } +} 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) From de8691243ce344f3360a667e2cd2ce704344e358 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Mon, 24 Mar 2025 07:11:04 -0600 Subject: [PATCH 02/23] Share settings cache between running processes (#6788) With the multi-process data store change, all processes will read the settings cache from the same file safely. This means if a second process started, it would read the cache the first process persisted. But if 2 processes were already running, and one fetched and cached new settings, it wouldn't automatically share it with the other running process. This change fixes that by having all processes watch the file. Also cleaned up settings a bit, and made everything in seconds to avoid converting units. Cleaned up unit tests. Also removed the need to lazy load the cache by doing a double check in the getter instead. There is more potential to clean up, but let's do it later. --- .../sessions/FirebaseSessionsTests.kt | 34 +-- .../sessions/settings/RemoteSettings.kt | 11 +- .../sessions/settings/SessionConfigs.kt | 4 +- .../sessions/settings/SettingsCache.kt | 37 ++- .../firebase/sessions/SessionDatastoreTest.kt | 2 +- .../sessions/settings/RemoteSettingsTest.kt | 268 +++++++----------- .../sessions/settings/SessionsSettingsTest.kt | 218 +++++++------- .../sessions/settings/SettingsCacheTest.kt | 123 ++++++-- .../sessions/testing/FakeSettingsCache.kt | 6 +- .../sessions/testing/TestDataStores.kt | 50 ---- 10 files changed, 365 insertions(+), 388 deletions(-) delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/TestDataStores.kt 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") }, - ) - } -} From 32c9222a3c72a80682124f10c4c678bafe1349c1 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Wed, 2 Apr 2025 09:32:49 -0600 Subject: [PATCH 03/23] Fix unit tests showing warning about datastore_shared_counter (#6830) Fix Robolectric tests showing warning about datastore_shared_counter by falling back to the normal DataStore instance. This is a known issue, see [b/352047731](http://b/352047731). --- .../sessions/FirebaseSessionsComponent.kt | 39 ++++++++++++++++++- .../google/firebase/sessions/SessionEvents.kt | 1 - 2 files changed, 37 insertions(+), 3 deletions(-) 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 99de9e4a3fc..918782ef140 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,8 +18,11 @@ 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.dataStoreFile import com.google.android.datatransport.TransportFactory @@ -43,6 +46,7 @@ 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 @@ -137,7 +141,7 @@ internal interface FirebaseSessionsComponent { appContext: Context, @Blocking blockingDispatcher: CoroutineContext, ): DataStore = - MultiProcessDataStoreFactory.create( + createDataStore( serializer = SessionConfigsSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> @@ -154,7 +158,7 @@ internal interface FirebaseSessionsComponent { appContext: Context, @Blocking blockingDispatcher: CoroutineContext, ): DataStore = - MultiProcessDataStoreFactory.create( + createDataStore( serializer = SessionDataSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> @@ -164,6 +168,37 @@ internal interface FirebaseSessionsComponent { 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/SessionEvents.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt index 25b3cbeb15d..6a540fd0104 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) { From 1656097f96da985179fdc5d8a45c30b35dd4fb93 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Wed, 9 Apr 2025 08:43:36 -0600 Subject: [PATCH 04/23] Create SharedSessionRepository and remove bound service (#6844) This removes all the bound service implementation, and starts the shared sessions repository implementation. The shared repository is responsible for sharing session data between processes in a simple way. This does not detect cold starts yet. The `SharedSessionRepository` class might be getting too much, we will consider splitting it up as implementation goes further. --- .../sessions/FirebaseSessionsTests.kt | 1 - .../SessionLifecycleServiceBinderTest.kt | 62 ---- .../src/main/AndroidManifest.xml | 10 +- .../firebase/sessions/FirebaseSessions.kt | 18 +- .../sessions/FirebaseSessionsComponent.kt | 18 +- .../google/firebase/sessions/SessionData.kt | 58 ++++ .../firebase/sessions/SessionDatastore.kt | 115 ------- .../firebase/sessions/SessionDetails.kt} | 29 +- .../firebase/sessions/SessionGenerator.kt | 54 +--- .../sessions/SessionLifecycleClient.kt | 217 -------------- .../sessions/SessionLifecycleService.kt | 262 ---------------- .../sessions/SessionLifecycleServiceBinder.kt | 77 ----- .../SessionsActivityLifecycleCallbacks.kt | 36 +-- .../sessions/SharedSessionRepository.kt | 121 ++++++++ .../google/firebase/sessions/TimeProvider.kt | 4 + .../src/test/AndroidManifest.xml | 33 -- .../firebase/sessions/ApplicationInfoTest.kt | 8 +- .../firebase/sessions/EventGDTLoggerTest.kt | 2 - .../firebase/sessions/SessionDatastoreTest.kt | 59 ---- .../sessions/SessionEventEncoderTest.kt | 2 - .../firebase/sessions/SessionEventTest.kt | 2 - .../firebase/sessions/SessionGeneratorTest.kt | 54 +--- .../sessions/SessionLifecycleClientTest.kt | 281 ------------------ .../sessions/SessionLifecycleServiceTest.kt | 234 --------------- .../SessionsActivityLifecycleCallbacksTest.kt | 132 -------- .../api/FirebaseSessionsDependenciesTest.kt | 2 - .../sessions/testing/FakeFirelogPublisher.kt | 34 --- .../FakeSessionLifecycleServiceBinder.kt | 88 ------ .../sessions/testing/FakeTransportFactory.kt | 22 +- .../testing/FirebaseSessionsFakeComponent.kt | 68 ----- .../testing/FirebaseSessionsFakeRegistrar.kt | 52 ---- 31 files changed, 270 insertions(+), 1885 deletions(-) delete mode 100644 firebase-sessions/src/androidTest/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinderTest.kt create mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt delete mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt rename firebase-sessions/src/{test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt => main/kotlin/com/google/firebase/sessions/SessionDetails.kt} (50%) delete mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleClient.kt delete mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleService.kt delete mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionLifecycleServiceBinder.kt create mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt delete mode 100644 firebase-sessions/src/test/AndroidManifest.xml delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleClientTest.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionLifecycleServiceTest.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacksTest.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeFirelogPublisher.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionLifecycleServiceBinder.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeComponent.kt delete mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FirebaseSessionsFakeRegistrar.kt 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 c74b6e4e329..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 @@ -42,7 +42,6 @@ class FirebaseSessionsTests { @Test fun firebaseSessionsDependenciesDoInitialize() { assertThat(SessionFirelogPublisher.instance).isNotNull() - assertThat(SessionGenerator.instance).isNotNull() assertThat(SessionsSettings.instance).isNotNull() } 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/FirebaseSessions.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt index 18b9961724b..b0bb9bca4c2 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt @@ -38,14 +38,14 @@ constructor( private val firebaseApp: FirebaseApp, private val settings: SessionsSettings, @Background backgroundDispatcher: CoroutineContext, - lifecycleServiceBinder: SessionLifecycleServiceBinder, + sessionsActivityLifecycleCallbacks: SessionsActivityLifecycleCallbacks, ) { init { Log.d(TAG, "Initializing Firebase Sessions SDK.") val appContext = firebaseApp.applicationContext.applicationContext if (appContext is Application) { - appContext.registerActivityLifecycleCallbacks(SessionsActivityLifecycleCallbacks) + appContext.registerActivityLifecycleCallbacks(sessionsActivityLifecycleCallbacks) CoroutineScope(backgroundDispatcher).launch { val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() @@ -56,16 +56,12 @@ constructor( if (!settings.sessionsEnabled) { Log.d(TAG, "Sessions SDK disabled. Not listening to lifecycle events.") } else { - val lifecycleClient = SessionLifecycleClient(backgroundDispatcher) - lifecycleClient.bindToService(lifecycleServiceBinder) - SessionsActivityLifecycleCallbacks.lifecycleClient = lifecycleClient - firebaseApp.addLifecycleEventListener { _, _ -> - Log.w( - TAG, - "FirebaseApp instance deleted. Sessions library will stop collecting data.", - ) - SessionsActivityLifecycleCallbacks.lifecycleClient = null + // Log.w( + // TAG, + // "FirebaseApp instance deleted. Sessions library will stop collecting data.", + // ) + // TODO(mrober): Clean up on firebase app delete } } } 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 918782ef140..bdeb73af736 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 @@ -66,7 +66,6 @@ import kotlinx.coroutines.CoroutineScope internal interface FirebaseSessionsComponent { val firebaseSessions: FirebaseSessions - val sessionDatastore: SessionDatastore val sessionFirelogPublisher: SessionFirelogPublisher val sessionGenerator: SessionGenerator val sessionsSettings: SessionsSettings @@ -95,18 +94,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 @@ -123,6 +114,10 @@ internal interface FirebaseSessionsComponent { @Binds @Singleton fun settingsCache(impl: SettingsCacheImpl): SettingsCache + @Binds + @Singleton + fun sharedSessionRepository(impl: SharedSessionRepositoryImpl): SharedSessionRepository + companion object { private const val TAG = "FirebaseSessions" @@ -157,13 +152,14 @@ internal interface FirebaseSessionsComponent { fun sessionDataStore( appContext: Context, @Blocking blockingDispatcher: CoroutineContext, + sessionDataSerializer: SessionDataSerializer, ): DataStore = createDataStore( - serializer = SessionDataSerializer, + serializer = sessionDataSerializer, corruptionHandler = ReplaceFileCorruptionHandler { ex -> Log.w(TAG, "CorruptionException in session data DataStore", ex) - SessionDataSerializer.defaultValue + sessionDataSerializer.defaultValue }, scope = CoroutineScope(blockingDispatcher), produceFile = { appContext.dataStoreFile("aqs/sessionDataStore.data") }, 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..3eaf1a6c011 --- /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) + +/** DataStore json [Serializer] for [SessionData]. */ +@Singleton +internal class SessionDataSerializer +@Inject +constructor( + private val sessionGenerator: SessionGenerator, + private val timeProvider: TimeProvider, +) : Serializer { + override val defaultValue: SessionData + get() = + SessionData( + sessionDetails = sessionGenerator.generateNewSession(currentSession = null), + backgroundTime = timeProvider.currentTime(), + ) + + 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/SessionDatastore.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt deleted file mode 100644 index b3b72b4d4d7..00000000000 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDatastore.kt +++ /dev/null @@ -1,115 +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.CorruptionException -import androidx.datastore.core.DataStore -import androidx.datastore.core.Serializer -import com.google.firebase.Firebase -import com.google.firebase.annotations.concurrent.Background -import com.google.firebase.app -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -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.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json - -/** Data for sessions information */ -@Serializable internal data class SessionData(val sessionId: String?) - -/** DataStore json [Serializer] for [SessionData]. */ -internal object SessionDataSerializer : Serializer { - override val defaultValue = SessionData(sessionId = 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()) - } -} - -/** 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, - private val sessionDataStore: DataStore, -) : SessionDatastore { - - /** Most recent session from datastore is updated asynchronously whenever it changes */ - private val currentSessionFromDatastore = AtomicReference() - - private val firebaseSessionDataFlow: Flow = - sessionDataStore.data.catch { ex -> - Log.e(TAG, "Error reading stored session data.", ex) - emit(SessionDataSerializer.defaultValue) - } - - init { - CoroutineScope(backgroundDispatcher).launch { - firebaseSessionDataFlow.collect { currentSessionFromDatastore.set(it) } - } - } - - override fun updateSessionId(sessionId: String) { - CoroutineScope(backgroundDispatcher).launch { - try { - sessionDataStore.updateData { SessionData(sessionId) } - } catch (ex: IOException) { - Log.w(TAG, "Failed to update session Id", ex) - } - } - } - - override fun getCurrentSessionId(): String? = currentSessionFromDatastore.get()?.sessionId - - private companion object { - private const val TAG = "FirebaseSessionsRepo" - } -} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt similarity index 50% rename from firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt rename to firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.kt index f98852032c8..c5f71fb65e5 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeSessionDatastore.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionDetails.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. @@ -14,22 +14,15 @@ * limitations under the License. */ -package com.google.firebase.sessions.testing +package com.google.firebase.sessions -import com.google.firebase.sessions.SessionDatastore +import kotlinx.serialization.Serializable -/** - * Fake implementaiton of the [SessionDatastore] that allows for inspecting and modifying the - * currently stored values in unit tests. - */ -internal class FakeSessionDatastore : SessionDatastore { - - /** The currently stored value */ - private var currentSessionId: String? = null - - override fun updateSessionId(sessionId: String) { - currentSessionId = sessionId - } - - override fun getCurrentSessionId() = currentSessionId -} +/** 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/SessionGenerator.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionGenerator.kt index 409f9989348..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.currentTime().us, - ) - 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..8b817316066 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,34 +19,22 @@ 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() - } - } - } - - override fun onActivityResumed(activity: Activity) { - lifecycleClient?.foregrounded() ?: run { hasPendingForeground = true } - } - - override fun onActivityPaused(activity: Activity) { - lifecycleClient?.backgrounded() - } +@Singleton +internal class SessionsActivityLifecycleCallbacks +@Inject +constructor(private val sharedSessionRepository: SharedSessionRepository) : + ActivityLifecycleCallbacks { + + override fun onActivityResumed(activity: Activity) = sharedSessionRepository.appForeground() + + override fun onActivityPaused(activity: Activity) = 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..234730985c0 --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -0,0 +1,121 @@ +/* + * 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.annotations.concurrent.Background +import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName +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.launch + +/** Repository to persist session data to be shared between all app processes. */ +internal interface SharedSessionRepository { + fun appBackground() + + fun appForeground() +} + +@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, + @Background private val backgroundDispatcher: CoroutineContext, +) : SharedSessionRepository { + /** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */ + private lateinit var localSessionData: SessionData + + init { + CoroutineScope(backgroundDispatcher).launch { + sessionDataStore.data.collect { sessionData -> + localSessionData = sessionData + val sessionId = sessionData.sessionDetails.sessionId + + 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") + } + } + } + } + + override fun appBackground() { + if (!::localSessionData.isInitialized) { + Log.d(TAG, "App backgrounded, but local SessionData not initialized") + return + } + val sessionData = localSessionData + Log.d(TAG, "App backgrounded on ${getProcessName()} - $sessionData") + + CoroutineScope(backgroundDispatcher).launch { + sessionDataStore.updateData { + // TODO(mrober): Double check time makes sense? + sessionData.copy(backgroundTime = timeProvider.currentTime()) + } + } + } + + override fun appForeground() { + if (!::localSessionData.isInitialized) { + Log.d(TAG, "App foregrounded, but local SessionData not initialized") + return + } + val sessionData = localSessionData + Log.d(TAG, "App foregrounded on ${getProcessName()} - $sessionData") + + if (shouldInitiateNewSession(sessionData)) { + // Generate new session details on main thread so the timestamp is as current as possible + val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) + + CoroutineScope(backgroundDispatcher).launch { + sessionDataStore.updateData { currentSessionData -> + // Double-check pattern + if (shouldInitiateNewSession(currentSessionData)) { + currentSessionData.copy(sessionDetails = newSessionDetails) + } else { + currentSessionData + } + } + } + + // TODO(mrober): If data collection is enabled for at least one subscriber... + // https://github.com/firebase/firebase-android-sdk/blob/a53ab64150608c2eb3eafb17d81dfe217687d955/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt#L110 + sessionFirelogPublisher.logSession(sessionDetails = newSessionDetails) + } + } + + private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { + val interval = timeProvider.currentTime() - sessionData.backgroundTime + return interval > sessionsSettings.sessionRestartTimeout + } + + private companion object { + const val TAG = "SharedSessionRepository" + } +} 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 869b64b2ff2..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,11 +19,15 @@ 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. */ 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..b7bdb7730e8 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 @@ -56,13 +56,13 @@ class ApplicationInfoTest { deviceManufacturer = Build.MANUFACTURER, actualCurrentProcessDetails, actualAppProcessDetails, - ) + ), ) ) } @Test - fun applicationInfo_missiongVersionCode_populatesInfoCorrectly() { + fun applicationInfo_missingVersionCode_populatesInfoCorrectly() { // Initialize Firebase with no version code set. val firebaseApp = Firebase.initialize( @@ -71,7 +71,7 @@ class ApplicationInfoTest { .setApplicationId(FakeFirebaseApp.MOCK_APP_ID) .setApiKey(FakeFirebaseApp.MOCK_API_KEY) .setProjectId(FakeFirebaseApp.MOCK_PROJECT_ID) - .build() + .build(), ) val actualCurrentProcessDetails = @@ -96,7 +96,7 @@ class ApplicationInfoTest { deviceManufacturer = Build.MANUFACTURER, actualCurrentProcessDetails, actualAppProcessDetails, - ) + ), ) ) } 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/SessionDatastoreTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt deleted file mode 100644 index efe7bb27a97..00000000000 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionDatastoreTest.kt +++ /dev/null @@ -1,59 +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 - -import android.content.Context -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -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.Test -import org.junit.runner.RunWith - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -class SessionDatastoreTest { - private val appContext: Context = ApplicationProvider.getApplicationContext() - - @Test - fun getCurrentSessionId_returnsLatest() = runTest { - val sessionDatastore = - SessionDatastoreImpl( - backgroundDispatcher = StandardTestDispatcher(testScheduler, "background"), - sessionDataStore = - DataStoreFactory.create( - serializer = SessionDataSerializer, - scope = CoroutineScope(StandardTestDispatcher(testScheduler, "blocking")), - produceFile = { appContext.dataStoreFile("sessionDataStore.data") }, - ), - ) - - sessionDatastore.updateSessionId("sessionId1") - sessionDatastore.updateSessionId("sessionId2") - sessionDatastore.updateSessionId("sessionId3") - - runCurrent() - - assertThat(sessionDatastore.getCurrentSessionId()).isEqualTo("sessionId3") - } -} 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/SessionGeneratorTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SessionGeneratorTest.kt index bf260e73a4f..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 @@ -39,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() } } @@ -88,12 +62,12 @@ 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, @@ -111,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() @@ -126,7 +100,8 @@ class SessionGeneratorTest { ) ) - val secondSessionDetails = sessionGenerator.generateNewSession() + val secondSessionDetails = + sessionGenerator.generateNewSession(currentSession = firstSessionDetails) assertThat(isValidSessionId(secondSessionDetails.sessionId)).isTrue() assertThat(isValidSessionId(secondSessionDetails.firstSessionId)).isTrue() @@ -143,7 +118,8 @@ class SessionGeneratorTest { ) // 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() 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/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/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/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/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/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) - } -} From 83d5f093fa8a054c0f939138aceffd724011e757 Mon Sep 17 00:00:00 2001 From: themiswang Date: Wed, 9 Apr 2025 12:13:27 -0400 Subject: [PATCH 05/23] send session event based on data collection and setting config (#6852) send session event based on data collection and setting config --- .../firebase/sessions/SessionFirelogPublisher.kt | 13 ++++++++----- .../firebase/sessions/SharedSessionRepository.kt | 5 +---- .../sessions/SessionFirelogPublisherTest.kt | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) 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..b6a3c87e1dc 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 @@ -34,7 +34,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 +64,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 +94,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 } 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 index 234730985c0..92b726bfc10 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -103,10 +103,7 @@ constructor( } } } - - // TODO(mrober): If data collection is enabled for at least one subscriber... - // https://github.com/firebase/firebase-android-sdk/blob/a53ab64150608c2eb3eafb17d81dfe217687d955/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt#L110 - sessionFirelogPublisher.logSession(sessionDetails = newSessionDetails) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) } } 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() From 22438cd85dc3056cb05f769d724b524a9c667c79 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Wed, 9 Apr 2025 12:57:40 -0600 Subject: [PATCH 06/23] Fix firelog send (#6855) Fix where we generate and send session details to firelog. Before this, we might send false positives to firelog, or even update the session details with a stale copy. Now the session generate and send to firelog happen inside the datastore transform with the data in datastore. --- .../com/google/firebase/sessions/SharedSessionRepository.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 index 92b726bfc10..fee1acd737e 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -90,20 +90,18 @@ constructor( Log.d(TAG, "App foregrounded on ${getProcessName()} - $sessionData") if (shouldInitiateNewSession(sessionData)) { - // Generate new session details on main thread so the timestamp is as current as possible - val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) - CoroutineScope(backgroundDispatcher).launch { sessionDataStore.updateData { currentSessionData -> // Double-check pattern if (shouldInitiateNewSession(currentSessionData)) { + val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) currentSessionData.copy(sessionDetails = newSessionDetails) } else { currentSessionData } } } - sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) } } From 149da18067d1a7dac6c6b6aa809426e64b324fc0 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Tue, 15 Apr 2025 08:55:31 -0600 Subject: [PATCH 07/23] Implement fake datastore for unit tests (#6874) Implement fake datastore for unit tests. This fake can act like a simple in memory datastore, but it can also throw provided exceptions on specific actions. It can throw on update, throw on collect, throw on init. This will help write unit tests for when datastore fails. --- .../firebase/sessions/FakeDataStoreTest.kt | 119 ++++++++++++++++++ .../sessions/testing/FakeDataStore.kt | 99 +++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/FakeDataStoreTest.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeDataStore.kt 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/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() + } +} From eb567a70b3b044ca24bb851df91a268fe7a4c67b Mon Sep 17 00:00:00 2001 From: themiswang Date: Tue, 15 Apr 2025 14:13:22 -0400 Subject: [PATCH 08/23] Fallback try catch block (#6873) Using try catch block to make datastore fall back to a pre multi-process supported version. Generate a new session locally then notify to subscribers --- .../sessions/SharedSessionRepository.kt | 94 ++++++++++++++----- 1 file changed, 73 insertions(+), 21 deletions(-) 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 index fee1acd737e..88189c5fe4a 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -27,7 +27,9 @@ 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 +import org.jetbrains.annotations.VisibleForTesting /** Repository to persist session data to be shared between all app processes. */ internal interface SharedSessionRepository { @@ -48,20 +50,38 @@ constructor( @Background private val backgroundDispatcher: CoroutineContext, ) : SharedSessionRepository { /** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */ - private lateinit var localSessionData: SessionData + @VisibleForTesting lateinit var localSessionData: SessionData + + /** + * Either notify the subscribers with general multi-process supported session or fallback local + * session + */ + private enum class NotificationType { + GENERAL, + FALLBACK + } init { + println("session repo init") CoroutineScope(backgroundDispatcher).launch { - sessionDataStore.data.collect { sessionData -> - localSessionData = sessionData - val sessionId = sessionData.sessionDetails.sessionId - - 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") + sessionDataStore.data + .catch { + val newSession = + SessionData( + sessionDetails = sessionGenerator.generateNewSession(null), + backgroundTime = timeProvider.currentTime() + ) + 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) } - } } } @@ -74,9 +94,14 @@ constructor( Log.d(TAG, "App backgrounded on ${getProcessName()} - $sessionData") CoroutineScope(backgroundDispatcher).launch { - sessionDataStore.updateData { - // TODO(mrober): Double check time makes sense? - sessionData.copy(backgroundTime = timeProvider.currentTime()) + try { + sessionDataStore.updateData { + // TODO(mrober): Double check time makes sense? + 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()) } } } @@ -91,20 +116,47 @@ constructor( if (shouldInitiateNewSession(sessionData)) { CoroutineScope(backgroundDispatcher).launch { - sessionDataStore.updateData { currentSessionData -> - // Double-check pattern - if (shouldInitiateNewSession(currentSessionData)) { - val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) - sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) - currentSessionData.copy(sessionDetails = newSessionDetails) - } else { - currentSessionData + try { + sessionDataStore.updateData { currentSessionData -> + // Double-check pattern + if (shouldInitiateNewSession(currentSessionData)) { + val newSessionDetails = + sessionGenerator.generateNewSession(sessionData.sessionDetails) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + currentSessionData.copy(sessionDetails = newSessionDetails) + } else { + currentSessionData + } } + } catch (ex: Exception) { + Log.d(TAG, "App appForegrounded, failed to update data. Message: ${ex.message}") + val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) + localSessionData = localSessionData.copy(sessionDetails = newSessionDetails) + sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + + val sessionId = newSessionDetails.sessionId + notifySubscribers(sessionId, NotificationType.FALLBACK) } } } } + private suspend fun notifySubscribers(sessionId: String, type: NotificationType) { + FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> + // Notify subscribers, regardless of sampling and data collection state + subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) + when (type) { + NotificationType.GENERAL -> + Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId") + NotificationType.FALLBACK -> + Log.d( + TAG, + "Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId" + ) + } + } + } + private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { val interval = timeProvider.currentTime() - sessionData.backgroundTime return interval > sessionsSettings.sessionRestartTimeout From 0c3633578d58bfcc4c51914e479995f1f0bf67a1 Mon Sep 17 00:00:00 2001 From: themiswang Date: Tue, 15 Apr 2025 16:31:33 -0400 Subject: [PATCH 09/23] Add unit tests for session repo (#6878) --- .../sessions/SharedSessionRepository.kt | 23 +- .../sessions/SharedSessionRepositoryTest.kt | 232 ++++++++++++++++++ 2 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt 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 index 88189c5fe4a..7c25ab7f8c0 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -29,7 +29,6 @@ import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch -import org.jetbrains.annotations.VisibleForTesting /** Repository to persist session data to be shared between all app processes. */ internal interface SharedSessionRepository { @@ -50,16 +49,17 @@ constructor( @Background private val backgroundDispatcher: CoroutineContext, ) : SharedSessionRepository { /** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */ - @VisibleForTesting lateinit var localSessionData: SessionData + internal lateinit var localSessionData: SessionData /** * Either notify the subscribers with general multi-process supported session or fallback local * session */ - private enum class NotificationType { + internal enum class NotificationType { GENERAL, FALLBACK } + internal var previousNotificationType: NotificationType = NotificationType.GENERAL init { println("session repo init") @@ -142,18 +142,19 @@ constructor( } private suspend fun notifySubscribers(sessionId: String, type: NotificationType) { + previousNotificationType = type FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> // Notify subscribers, regardless of sampling and data collection state subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) - when (type) { - NotificationType.GENERAL -> - Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId") - NotificationType.FALLBACK -> - Log.d( - TAG, + Log.d( + TAG, + when (type) { + NotificationType.GENERAL -> + "Notified ${subscriber.sessionSubscriberName} of new session $sessionId" + NotificationType.FALLBACK -> "Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId" - ) - } + } + ) } } 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..301f748f05a --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt @@ -0,0 +1,232 @@ +/* + * 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.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.launch +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, + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + ) + runCurrent() + fakeDataStore.close() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_INIT) + } + + @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, + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + ) + runCurrent() + fakeDataStore.close() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_1) + } + + @Test + fun appForegroundSharedSessionRepo_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, + ), + fakeTimeProvider.currentTime(), + ) + ) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + ) + backgroundScope.launch { + fakeTimeProvider.addInterval(20.hours) + sharedSessionRepository.appForeground() + } + runCurrent() + assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId) + .isEqualTo(SESSION_ID_1) + assertThat(sharedSessionRepository.previousNotificationType) + .isEqualTo(SharedSessionRepositoryImpl.NotificationType.GENERAL) + fakeDataStore.close() + } + + @Test + fun appForegroundSharedSessionRepo_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") + ) + fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed")) + val sharedSessionRepository = + SharedSessionRepositoryImpl( + sessionsSettings, + sessionGenerator, + sessionFirelogPublisher, + fakeTimeProvider, + fakeDataStore, + backgroundDispatcher = + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + ) + + backgroundScope.launch { + fakeTimeProvider.addInterval(20.hours) + 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.previousNotificationType) + .isEqualTo(SharedSessionRepositoryImpl.NotificationType.FALLBACK) + fakeDataStore.close() + } + + companion object { + const val SESSION_ID_INIT = "12345678901234546677960" + } +} From 9dd77e8dc9d6dc6fbf0301e69a97300f89ebb249 Mon Sep 17 00:00:00 2001 From: themiswang Date: Wed, 16 Apr 2025 14:19:50 -0400 Subject: [PATCH 10/23] background time nullable (#6886) Make background time nullable For each new session generated, reset background time Update unit tests --- .../google/firebase/sessions/SessionData.kt | 11 +++---- .../sessions/SharedSessionRepository.kt | 16 ++++++---- .../sessions/SharedSessionRepositoryTest.kt | 32 ++++++++++++------- 3 files changed, 35 insertions(+), 24 deletions(-) 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 index 3eaf1a6c011..84e05b89ed9 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt @@ -27,7 +27,10 @@ import kotlinx.serialization.json.Json /** Session data to be persisted. */ @Serializable -internal data class SessionData(val sessionDetails: SessionDetails, val backgroundTime: Time) +internal data class SessionData( + val sessionDetails: SessionDetails, + val backgroundTime: Time? = null +) /** DataStore json [Serializer] for [SessionData]. */ @Singleton @@ -38,11 +41,7 @@ constructor( private val timeProvider: TimeProvider, ) : Serializer { override val defaultValue: SessionData - get() = - SessionData( - sessionDetails = sessionGenerator.generateNewSession(currentSession = null), - backgroundTime = timeProvider.currentTime(), - ) + get() = SessionData(sessionGenerator.generateNewSession(currentSession = null)) override suspend fun readFrom(input: InputStream): SessionData = try { 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 index 7c25ab7f8c0..5606342a428 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -62,14 +62,13 @@ constructor( internal var previousNotificationType: NotificationType = NotificationType.GENERAL init { - println("session repo init") CoroutineScope(backgroundDispatcher).launch { sessionDataStore.data .catch { val newSession = SessionData( sessionDetails = sessionGenerator.generateNewSession(null), - backgroundTime = timeProvider.currentTime() + backgroundTime = null ) Log.d( TAG, @@ -123,7 +122,7 @@ constructor( val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) - currentSessionData.copy(sessionDetails = newSessionDetails) + currentSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) } else { currentSessionData } @@ -131,7 +130,8 @@ constructor( } catch (ex: Exception) { Log.d(TAG, "App appForegrounded, failed to update data. Message: ${ex.message}") val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) - localSessionData = localSessionData.copy(sessionDetails = newSessionDetails) + localSessionData = + localSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) val sessionId = newSessionDetails.sessionId @@ -159,8 +159,12 @@ constructor( } private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { - val interval = timeProvider.currentTime() - sessionData.backgroundTime - return interval > sessionsSettings.sessionRestartTimeout + sessionData.backgroundTime?.let { + val interval = timeProvider.currentTime() - it + return interval > sessionsSettings.sessionRestartTimeout + } + Log.d(TAG, "No process has backgrounded yet, should not change the session.") + return false } private companion object { 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 index 301f748f05a..135b8df4f22 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt @@ -33,7 +33,6 @@ import com.google.firebase.sessions.testing.FakeUuidGenerator import kotlin.time.Duration.Companion.hours import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After @@ -135,7 +134,7 @@ class SharedSessionRepositoryTest { } @Test - fun appForegroundSharedSessionRepo_updateSuccess() = runTest { + fun appForegroundGenerateNewSession_updateSuccess() = runTest { val sessionFirelogPublisher = SessionFirelogPublisherImpl( fakeFirebaseApp.firebaseApp, @@ -166,20 +165,22 @@ class SharedSessionRepositoryTest { backgroundDispatcher = TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext ) - backgroundScope.launch { - fakeTimeProvider.addInterval(20.hours) - sharedSessionRepository.appForeground() - } 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 appForegroundSharedSessionRepo_updateFail() = runTest { + fun appForegroundGenerateNewSession_updateFail() = runTest { val sessionFirelogPublisher = SessionFirelogPublisherImpl( fakeFirebaseApp.firebaseApp, @@ -201,7 +202,6 @@ class SharedSessionRepositoryTest { ), IllegalArgumentException("Datastore init failed") ) - fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed")) val sharedSessionRepository = SharedSessionRepositoryImpl( sessionsSettings, @@ -212,15 +212,23 @@ class SharedSessionRepositoryTest { backgroundDispatcher = TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext ) + runCurrent() - backgroundScope.launch { - fakeTimeProvider.addInterval(20.hours) - sharedSessionRepository.appForeground() - } + // 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() From bae2d49d3d9305211016424cd45c965ae7e35fb7 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Wed, 16 Apr 2025 13:31:41 -0600 Subject: [PATCH 11/23] Disable the activity lifecycle callbacks on app delete (#6877) --- .../firebase/sessions/FirebaseSessions.kt | 10 ++++----- .../SessionsActivityLifecycleCallbacks.kt | 21 +++++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) 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 b0bb9bca4c2..3830f34a1eb 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 @@ -57,11 +57,11 @@ constructor( Log.d(TAG, "Sessions SDK disabled. Not listening to lifecycle events.") } else { firebaseApp.addLifecycleEventListener { _, _ -> - // Log.w( - // TAG, - // "FirebaseApp instance deleted. Sessions library will stop collecting data.", - // ) - // TODO(mrober): Clean up on firebase app delete + Log.w( + TAG, + "FirebaseApp instance deleted. Sessions library will stop collecting data.", + ) + sessionsActivityLifecycleCallbacks.onAppDelete() } } } 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 8b817316066..0cdfbd1a498 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt @@ -31,10 +31,23 @@ internal class SessionsActivityLifecycleCallbacks @Inject constructor(private val sharedSessionRepository: SharedSessionRepository) : ActivityLifecycleCallbacks { - - override fun onActivityResumed(activity: Activity) = sharedSessionRepository.appForeground() - - override fun onActivityPaused(activity: Activity) = sharedSessionRepository.appBackground() + private var enabled = true + + fun onAppDelete() { + enabled = false + } + + override fun onActivityResumed(activity: Activity) { + if (enabled) { + sharedSessionRepository.appForeground() + } + } + + override fun onActivityPaused(activity: Activity) { + if (enabled) { + sharedSessionRepository.appBackground() + } + } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit From da6c7765afd7e241a6e9996df1851b61987e0f44 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 9 May 2025 12:37:00 -0700 Subject: [PATCH 12/23] Implement new cold app start detection heuristic (#6950) Implement new cold app start detection heuristic. This is just the reading part, and all the scaffolding. Next step will be to start writing the process data map. Tested by unit tests and manual testing with the sessions test app --- firebase-sessions/CHANGELOG.md | 7 +- .../firebase-sessions.gradle.kts | 6 +- .../sessions/FirebaseSessionsComponent.kt | 2 + .../firebase/sessions/ProcessDataManager.kt | 97 ++++++++++++++ .../google/firebase/sessions/SessionData.kt | 11 +- .../sessions/SharedSessionRepository.kt | 28 ++-- .../sessions/ProcessDataManagerTest.kt | 120 ++++++++++++++++++ .../sessions/SharedSessionRepositoryTest.kt | 97 +++++++++----- .../sessions/testing/FakeFirebaseApp.kt | 23 ++-- .../testing/FakeProcessDataManager.kt | 48 +++++++ .../testing/FakeRunningAppProcessInfo.kt | 34 +++++ .../sessions/testing/FakeUuidGenerator.kt | 2 + 12 files changed, 412 insertions(+), 63 deletions(-) create mode 100644 firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt create mode 100644 firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt diff --git a/firebase-sessions/CHANGELOG.md b/firebase-sessions/CHANGELOG.md index 55e15d56850..7b9be5d2acd 100644 --- a/firebase-sessions/CHANGELOG.md +++ b/firebase-sessions/CHANGELOG.md @@ -1,15 +1,10 @@ # Unreleased * [changed] Use multi-process DataStore instead of Preferences DataStore +* [changed] Update the heuristic to detect cold app starts # 2.1.1 * [unchanged] Updated to keep SDK versions aligned. - -## Kotlin -The Kotlin extensions library transitively includes the updated -`firebase-sessions` library. The Kotlin extensions library has no additional -updates. - # 2.1.0 * [changed] Add warning for known issue b/328687152 * [changed] Use Dagger for dependency injection diff --git a/firebase-sessions/firebase-sessions.gradle.kts b/firebase-sessions/firebase-sessions.gradle.kts index 23edc952d5e..6c4b56a1c3b 100644 --- a/firebase-sessions/firebase-sessions.gradle.kts +++ b/firebase-sessions/firebase-sessions.gradle.kts @@ -29,7 +29,11 @@ firebaseLibrary { testLab.enabled = true publishJavadoc = false - releaseNotes { enabled.set(false) } + + releaseNotes { + enabled = false + hasKTX = false + } } android { 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 bdeb73af736..6eb15149cb3 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 @@ -118,6 +118,8 @@ internal interface FirebaseSessionsComponent { @Singleton fun sharedSessionRepository(impl: SharedSessionRepositoryImpl): SharedSessionRepository + @Binds @Singleton fun processDataManager(impl: ProcessDataManagerImpl): ProcessDataManager + companion object { private const val TAG = "FirebaseSessions" 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..3b56aff8f5c --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt @@ -0,0 +1,97 @@ +/* + * 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 { + /** An in-memory uuid to uniquely identify this instance of this process. */ + 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 + + /** 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 with the current process only. */ + fun generateProcessDataMap() = updateProcessDataMap(mapOf()) +} + +/** Manage process data, used for detecting cold app starts. */ +@Singleton +internal class ProcessDataManagerImpl +@Inject +constructor(private val appContext: Context, private val uuidGenerator: UuidGenerator) : + ProcessDataManager { + override val myUuid: String by lazy { uuidGenerator.next().toString() } + + private val myProcessName: String by lazy { + ProcessDetailsProvider.getCurrentProcessDetails(appContext).processName + } + + 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 + } + + return ProcessDetailsProvider.getAppProcessDetails(appContext) + .mapNotNull { processDetails -> + processDataMap[processDetails.processName]?.let { processData -> + Pair(processDetails, processData) + } + } + .all { (processDetails, processData) -> isProcessStale(processDetails, processData) } + } + + 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)) + + /** + * Returns true if the process is stale, meaning the persisted process data does not match the + * running process details. + */ + private fun isProcessStale( + runningProcessDetails: ProcessDetails, + persistedProcessData: ProcessData, + ): Boolean = + if (myProcessName == runningProcessDetails.processName) { + runningProcessDetails.pid != persistedProcessData.pid || myUuid != persistedProcessData.uuid + } else { + runningProcessDetails.pid != persistedProcessData.pid + } +} 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 index 84e05b89ed9..8af2eee544d 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt @@ -29,17 +29,18 @@ import kotlinx.serialization.json.Json @Serializable internal data class SessionData( val sessionDetails: SessionDetails, - val backgroundTime: Time? = null + 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, - private val timeProvider: TimeProvider, -) : Serializer { +constructor(private val sessionGenerator: SessionGenerator) : Serializer { override val defaultValue: SessionData get() = SessionData(sessionGenerator.generateNewSession(currentSession = null)) 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 index 5606342a428..cec9362725f 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -46,6 +46,7 @@ constructor( 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. */ @@ -57,8 +58,9 @@ constructor( */ internal enum class NotificationType { GENERAL, - FALLBACK + FALLBACK, } + internal var previousNotificationType: NotificationType = NotificationType.GENERAL init { @@ -68,11 +70,11 @@ constructor( val newSession = SessionData( sessionDetails = sessionGenerator.generateNewSession(null), - backgroundTime = null + backgroundTime = null, ) Log.d( TAG, - "Init session datastore failed with exception message: ${it.message}. Emit fallback session ${newSession.sessionDetails.sessionId}" + "Init session datastore failed with exception message: ${it.message}. Emit fallback session ${newSession.sessionDetails.sessionId}", ) emit(newSession) } @@ -122,6 +124,7 @@ constructor( val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) + processDataManager.onSessionGenerated() currentSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) } else { currentSessionData @@ -153,17 +156,26 @@ constructor( "Notified ${subscriber.sessionSubscriberName} of new session $sessionId" NotificationType.FALLBACK -> "Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId" - } + }, ) } } private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { - sessionData.backgroundTime?.let { - val interval = timeProvider.currentTime() - it - return interval > sessionsSettings.sessionRestartTimeout + sessionData.backgroundTime?.let { backgroundTime -> + val interval = timeProvider.currentTime() - backgroundTime + if (interval > sessionsSettings.sessionRestartTimeout) { + Log.d(TAG, "Passed session restart timeout, so initiate a new session") + return true + } } - Log.d(TAG, "No process has backgrounded yet, should not change the session.") + + sessionData.processDataMap?.let { processDataMap -> + Log.d(TAG, "Has not passed session restart timeout, so check for cold app start") + return processDataManager.isColdStart(processDataMap) + } + + Log.d(TAG, "No process has backgrounded yet and no process data, should not change the session") return false } 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..4bbeea6954c --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt @@ -0,0 +1,120 @@ +/* + * 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 +import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2 +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(UUID_1)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1))) + + assertThat(coldStart).isFalse() + } + + fun isColdStart_myProcessCurrent_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val coldStart = + processDataManager.isColdStart( + mapOf( + MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2), + ) + ) + + assertThat(coldStart).isFalse() + } + + @Test + fun isColdStart_staleProcessPid() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1))) + + assertThat(coldStart).isTrue() + } + + @Test + fun isColdStart_staleProcessUuid() { + val appContext = FakeFirebaseApp().firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val coldStart = + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_2))) + + assertThat(coldStart).isTrue() + } + + @Test + fun isColdStart_myProcessStale_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val coldStart = + processDataManager.isColdStart( + mapOf( + MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2), + ) + ) + + assertThat(coldStart).isFalse() + } + + @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/SharedSessionRepositoryTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt index 135b8df4f22..38ab0ca37a4 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/SharedSessionRepositoryTest.kt @@ -27,6 +27,7 @@ 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 @@ -67,14 +68,9 @@ class SharedSessionRepositoryTest { TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) val fakeDataStore = - FakeDataStore( + FakeDataStore( SessionData( - SessionDetails( - SESSION_ID_INIT, - SESSION_ID_INIT, - 0, - fakeTimeProvider.currentTime().ms, - ), + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), fakeTimeProvider.currentTime(), ) ) @@ -85,8 +81,9 @@ class SharedSessionRepositoryTest { sessionFirelogPublisher = publisher, timeProvider = fakeTimeProvider, sessionDataStore = fakeDataStore, + processDataManager = FakeProcessDataManager(), backgroundDispatcher = - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) runCurrent() fakeDataStore.close() @@ -94,6 +91,47 @@ class SharedSessionRepositoryTest { .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_INIT, 1, fakeTimeProvider.currentTime().us) + ) + } + @Test fun initSharedSessionRepo_initException() = runTest { val sessionFirelogPublisher = @@ -105,17 +143,12 @@ class SharedSessionRepositoryTest { TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) val fakeDataStore = - FakeDataStore( + FakeDataStore( SessionData( - SessionDetails( - SESSION_ID_INIT, - SESSION_ID_INIT, - 0, - fakeTimeProvider.currentTime().ms, - ), + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), fakeTimeProvider.currentTime(), ), - IllegalArgumentException("Datastore init failed") + IllegalArgumentException("Datastore init failed"), ) val sharedSessionRepository = SharedSessionRepositoryImpl( @@ -124,8 +157,9 @@ class SharedSessionRepositoryTest { sessionFirelogPublisher, fakeTimeProvider, fakeDataStore, + processDataManager = FakeProcessDataManager(), backgroundDispatcher = - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) runCurrent() fakeDataStore.close() @@ -144,15 +178,11 @@ class SharedSessionRepositoryTest { TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) val fakeDataStore = - FakeDataStore( + FakeDataStore( SessionData( - SessionDetails( - SESSION_ID_INIT, - SESSION_ID_INIT, - 0, - fakeTimeProvider.currentTime().ms, - ), - fakeTimeProvider.currentTime(), + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), + backgroundTime = fakeTimeProvider.currentTime(), + processDataMap = mapOf("" to ProcessData(pid = 10, uuid = "uuid")), ) ) val sharedSessionRepository = @@ -162,8 +192,9 @@ class SharedSessionRepositoryTest { sessionFirelogPublisher, fakeTimeProvider, fakeDataStore, + processDataManager = FakeProcessDataManager(), backgroundDispatcher = - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) runCurrent() @@ -190,17 +221,12 @@ class SharedSessionRepositoryTest { TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) val fakeDataStore = - FakeDataStore( + FakeDataStore( SessionData( - SessionDetails( - SESSION_ID_INIT, - SESSION_ID_INIT, - 0, - fakeTimeProvider.currentTime().ms, - ), + SessionDetails(SESSION_ID_INIT, SESSION_ID_INIT, 0, fakeTimeProvider.currentTime().ms), fakeTimeProvider.currentTime(), ), - IllegalArgumentException("Datastore init failed") + IllegalArgumentException("Datastore init failed"), ) val sharedSessionRepository = SharedSessionRepositoryImpl( @@ -209,8 +235,9 @@ class SharedSessionRepositoryTest { sessionFirelogPublisher, fakeTimeProvider, fakeDataStore, + processDataManager = FakeProcessDataManager(), backgroundDispatcher = - TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext + TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext, ) runCurrent() 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/FakeProcessDataManager.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt new file mode 100644 index 00000000000..fc43a502476 --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt @@ -0,0 +1,48 @@ +/* + * 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] until [onSessionGenerated] gets called, then returns false. + */ +internal class FakeProcessDataManager( + private val coldStart: Boolean = false, + 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 onSessionGenerated() { + hasGeneratedSession = true + } + + override fun updateProcessDataMap( + processDataMap: Map? + ): Map { + TODO("Not yet implemented") + } +} diff --git a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt new file mode 100644 index 00000000000..1afebb2d0bb --- /dev/null +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeRunningAppProcessInfo.kt @@ -0,0 +1,34 @@ +/* + * 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.app.ActivityManager + +/** 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 + } +} 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]) From 6106d064252f151f4c01bb61b433a932e7158d3c Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Thu, 15 May 2025 10:31:51 -0700 Subject: [PATCH 13/23] Implement ProcessDataManager (#6961) Implement ProcessDataManager in the way we designed in https://docs.google.com/document/d/1yEd8dCIzwNwLhRRwkDtzWFx9vxV5d-eQU-P47wDL40k/edit?usp=sharing Tested manually in the sessions test app, and with unit tests --- .../firebase/sessions/ProcessDataManager.kt | 59 ++++++++++++++----- .../sessions/ProcessDataManagerTest.kt | 47 +++++++++++++++ .../testing/FakeProcessDataManager.kt | 6 ++ 3 files changed, 96 insertions(+), 16 deletions(-) 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 index 3b56aff8f5c..f9cc5ab2a5a 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt @@ -23,32 +23,48 @@ import javax.inject.Singleton /** Manage process data, used for detecting cold app starts. */ internal interface ProcessDataManager { - /** An in-memory uuid to uniquely identify this instance of this process. */ + /** 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 with the current process only. */ - fun generateProcessDataMap() = updateProcessDataMap(mapOf()) + /** Generate a new mapping of process data about this process only. */ + fun generateProcessDataMap(): Map = updateProcessDataMap(emptyMap()) } -/** Manage process data, used for detecting cold app starts. */ @Singleton internal class ProcessDataManagerImpl @Inject -constructor(private val appContext: Context, private val uuidGenerator: UuidGenerator) : - ProcessDataManager { +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 myProcessName: String by lazy { - ProcessDetailsProvider.getCurrentProcessDetails(appContext).processName + private val myProcessDetails by lazy { + ProcessDetailsProvider.getCurrentProcessDetails(appContext) } private var hasGeneratedSession: Boolean = false @@ -59,7 +75,8 @@ constructor(private val appContext: Context, private val uuidGenerator: UuidGene return false } - return ProcessDetailsProvider.getAppProcessDetails(appContext) + // A cold start is when all app processes are stale + return getAppProcessDetails() .mapNotNull { processDetails -> processDataMap[processDetails.processName]?.let { processData -> Pair(processDetails, processData) @@ -68,6 +85,11 @@ constructor(private val appContext: Context, private val uuidGenerator: UuidGene .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 } @@ -81,17 +103,22 @@ constructor(private val appContext: Context, private val uuidGenerator: UuidGene ?.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( - runningProcessDetails: ProcessDetails, - persistedProcessData: ProcessData, - ): Boolean = - if (myProcessName == runningProcessDetails.processName) { - runningProcessDetails.pid != persistedProcessData.pid || myUuid != persistedProcessData.uuid + 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 { - runningProcessDetails.pid != persistedProcessData.pid + // 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/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt index 4bbeea6954c..9ac360aa9f4 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt @@ -100,6 +100,53 @@ internal class ProcessDataManagerTest { assertThat(coldStart).isFalse() } + @Test + fun isMyProcessStale() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo)).firebaseApp.applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val myProcessStale = + processDataManager.isMyProcessStale(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1))) + + assertThat(myProcessStale).isFalse() + } + + @Test + fun isMyProcessStale_otherProcessCurrent() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val myProcessStale = + processDataManager.isMyProcessStale( + mapOf( + MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2), + ) + ) + + assertThat(myProcessStale).isTrue() + } + + @Test + fun isMyProcessStale_missingMyProcessData() { + val appContext = + FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) + .firebaseApp + .applicationContext + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + + val myProcessStale = + processDataManager.isMyProcessStale( + mapOf(OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2)) + ) + + assertThat(myProcessStale).isTrue() + } + @After fun cleanUp() { FirebaseApp.clearInstancesForTest() 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 index fc43a502476..8458c357f5d 100644 --- 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 @@ -25,6 +25,9 @@ import com.google.firebase.sessions.ProcessDataManager */ internal class FakeProcessDataManager( private val coldStart: Boolean = false, + private var myProcessStale: Boolean = false, + 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 @@ -33,9 +36,12 @@ internal class FakeProcessDataManager( if (hasGeneratedSession) { return false } + return coldStart } + override fun isMyProcessStale(processDataMap: Map): Boolean = myProcessStale + override fun onSessionGenerated() { hasGeneratedSession = true } From e4d5edf467937281517ac5c9e163da1eb93b3829 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Tue, 20 May 2025 13:40:18 -0700 Subject: [PATCH 14/23] Implement cold start detection logic (#6975) Implement cold start detection logic as described in https://docs.google.com/document/d/1yEd8dCIzwNwLhRRwkDtzWFx9vxV5d-eQU-P47wDL40k/edit?usp=sharing Also renamed process related functions to be more consistent to make the code less error prone. This is also described in the doc Tested by unit tests and with the sessions test app --- .../firebase/sessions/ProcessDataManager.kt | 4 +- .../sessions/ProcessDetailsProvider.kt | 28 ++---- .../google/firebase/sessions/SessionEvents.kt | 2 +- .../sessions/SharedSessionRepository.kt | 96 ++++++++++++++----- .../firebase/sessions/ApplicationInfoTest.kt | 20 ++-- .../sessions/ProcessDataManagerTest.kt | 52 ++++++---- .../sessions/ProcessDetailsProviderTest.kt | 5 +- .../testing/FakeProcessDataManager.kt | 17 ++-- 8 files changed, 137 insertions(+), 87 deletions(-) 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 index f9cc5ab2a5a..295b6550ed7 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/ProcessDataManager.kt @@ -63,9 +63,7 @@ constructor(private val appContext: Context, uuidGenerator: UuidGenerator) : Pro override val myUuid: String by lazy { uuidGenerator.next().toString() } - private val myProcessDetails by lazy { - ProcessDetailsProvider.getCurrentProcessDetails(appContext) - } + private val myProcessDetails by lazy { ProcessDetailsProvider.getMyProcessDetails(appContext) } private var hasGeneratedSession: Boolean = false 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/SessionEvents.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionEvents.kt index 6a540fd0104..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 @@ -83,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/SharedSessionRepository.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt index cec9362725f..6cbaea417d3 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -19,7 +19,6 @@ package com.google.firebase.sessions import android.util.Log import androidx.datastore.core.DataStore import com.google.firebase.annotations.concurrent.Background -import com.google.firebase.sessions.ProcessDetailsProvider.getProcessName import com.google.firebase.sessions.api.FirebaseSessionsDependencies import com.google.firebase.sessions.api.SessionSubscriber import com.google.firebase.sessions.settings.SessionsSettings @@ -92,7 +91,7 @@ constructor( return } val sessionData = localSessionData - Log.d(TAG, "App backgrounded on ${getProcessName()} - $sessionData") + Log.d(TAG, "App backgrounded on ${processDataManager.myProcessName} - $sessionData") CoroutineScope(backgroundDispatcher).launch { try { @@ -113,32 +112,58 @@ constructor( return } val sessionData = localSessionData - Log.d(TAG, "App foregrounded on ${getProcessName()} - $sessionData") + Log.d(TAG, "App foregrounded on ${processDataManager.myProcessName} - $sessionData") - if (shouldInitiateNewSession(sessionData)) { + // Check if maybe the session data needs to be updated + if (isSessionExpired(sessionData) || isMyProcessStale(sessionData)) { CoroutineScope(backgroundDispatcher).launch { try { sessionDataStore.updateData { currentSessionData -> - // Double-check pattern - if (shouldInitiateNewSession(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 + } + + // This is an expression, and returns the updated session data + if (isSessionExpired || isColdStart) { val newSessionDetails = - sessionGenerator.generateNewSession(sessionData.sessionDetails) + sessionGenerator.generateNewSession(currentSessionData.sessionDetails) sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) processDataManager.onSessionGenerated() - currentSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) + 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 appForegrounded, failed to update data. Message: ${ex.message}") - val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails) - localSessionData = - localSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null) - sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails) - - val sessionId = newSessionDetails.sessionId - notifySubscribers(sessionId, NotificationType.FALLBACK) + 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) + } } } } @@ -161,22 +186,47 @@ constructor( } } - private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { + /** 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 - if (interval > sessionsSettings.sessionRestartTimeout) { - Log.d(TAG, "Passed session restart timeout, so initiate a new session") - return true + 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 -> - Log.d(TAG, "Has not passed session restart timeout, so check for cold app start") - return processDataManager.isColdStart(processDataMap) + val coldStart = processDataManager.isColdStart(processDataMap) + if (coldStart) { + Log.d(TAG, "Cold app start detected") + } + return coldStart } - Log.d(TAG, "No process has backgrounded yet and no process data, should not change the session") - return false + 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 } private companion object { 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 b7bdb7730e8..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,8 +54,8 @@ class ApplicationInfoTest { versionName = FakeFirebaseApp.MOCK_APP_VERSION, appBuildVersion = FakeFirebaseApp.MOCK_APP_BUILD_VERSION, deviceManufacturer = Build.MANUFACTURER, - actualCurrentProcessDetails, - actualAppProcessDetails, + myProcessDetails, + appProcessDetails, ), ) ) @@ -74,9 +74,9 @@ class ApplicationInfoTest { .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,8 +94,8 @@ 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/ProcessDataManagerTest.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt index 9ac360aa9f4..3eddd371a0f 100644 --- a/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt +++ b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/ProcessDataManagerTest.kt @@ -22,8 +22,8 @@ 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 -import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2 +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 @@ -33,26 +33,36 @@ internal class ProcessDataManagerTest { @Test fun isColdStart_myProcess() { val appContext = FakeFirebaseApp().firebaseApp.applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val coldStart = - processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1))) + 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(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val coldStart = processDataManager.isColdStart( mapOf( - MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1), - OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2), + MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), ) ) @@ -62,10 +72,10 @@ internal class ProcessDataManagerTest { @Test fun isColdStart_staleProcessPid() { val appContext = FakeFirebaseApp().firebaseApp.applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val coldStart = - processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1))) + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID))) assertThat(coldStart).isTrue() } @@ -73,10 +83,10 @@ internal class ProcessDataManagerTest { @Test fun isColdStart_staleProcessUuid() { val appContext = FakeFirebaseApp().firebaseApp.applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val coldStart = - processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_2))) + processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, OTHER_UUID))) assertThat(coldStart).isTrue() } @@ -87,13 +97,13 @@ internal class ProcessDataManagerTest { FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) .firebaseApp .applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val coldStart = processDataManager.isColdStart( mapOf( - MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1), - OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2), + MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), ) ) @@ -104,10 +114,10 @@ internal class ProcessDataManagerTest { fun isMyProcessStale() { val appContext = FakeFirebaseApp(processes = listOf(myProcessInfo)).firebaseApp.applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val myProcessStale = - processDataManager.isMyProcessStale(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1))) + processDataManager.isMyProcessStale(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID))) assertThat(myProcessStale).isFalse() } @@ -118,13 +128,13 @@ internal class ProcessDataManagerTest { FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) .firebaseApp .applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val myProcessStale = processDataManager.isMyProcessStale( mapOf( - MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1), - OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2), + MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID), + OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID), ) ) @@ -137,11 +147,11 @@ internal class ProcessDataManagerTest { FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo)) .firebaseApp .applicationContext - val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1)) + val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID)) val myProcessStale = processDataManager.isMyProcessStale( - mapOf(OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2)) + mapOf(OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID)) ) assertThat(myProcessStale).isTrue() 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/testing/FakeProcessDataManager.kt b/firebase-sessions/src/test/kotlin/com/google/firebase/sessions/testing/FakeProcessDataManager.kt index 8458c357f5d..d6e287196d4 100644 --- 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 @@ -21,11 +21,12 @@ import com.google.firebase.sessions.ProcessDataManager /** * Fake implementation of ProcessDataManager that returns the provided [coldStart] value for - * [isColdStart] until [onSessionGenerated] gets called, then returns false. + * [isColdStart], and similar for [isMyProcessStale], until [onSessionGenerated] gets called then + * returns false. */ internal class FakeProcessDataManager( private val coldStart: Boolean = false, - private var myProcessStale: 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, @@ -40,7 +41,13 @@ internal class FakeProcessDataManager( return coldStart } - override fun isMyProcessStale(processDataMap: Map): Boolean = myProcessStale + override fun isMyProcessStale(processDataMap: Map): Boolean { + if (hasGeneratedSession) { + return false + } + + return myProcessStale + } override fun onSessionGenerated() { hasGeneratedSession = true @@ -48,7 +55,5 @@ internal class FakeProcessDataManager( override fun updateProcessDataMap( processDataMap: Map? - ): Map { - TODO("Not yet implemented") - } + ): Map = processDataMap ?: emptyMap() } From 92e5b41732502aebfb3c068ec410cc9cb55395ce Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 23 May 2025 06:46:26 -0700 Subject: [PATCH 15/23] Add benchmark to measure startup time with cleared app data (#6985) --- .../benchmark/sessions/StartupBenchmark.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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..841cd073ba0 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,7 @@ 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 org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -31,7 +32,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 +40,24 @@ class StartupBenchmark { pressHome() startActivityAndWait() } + + @Test + fun startup_clearAppData() = + benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, + metrics = listOf(StartupTimingMetric()), + iterations = 5, + startupMode = StartupMode.COLD, + ) { + pressHome() + InstrumentationRegistry.getInstrumentation() + .uiAutomation + .executeShellCommand("pm clear $PACKAGE_NAME") + .close() + startActivityAndWait() + } + + private companion object { + const val PACKAGE_NAME = "com.google.firebase.testing.sessions" + } } From 788be88e08294539c36540db0d91a44d5e693c29 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Mon, 26 May 2025 10:32:27 -0700 Subject: [PATCH 16/23] Do not notify subscribers when session didn't change (#6988) Tested manually using sessions test app. This is a trivial change --- .../com/google/firebase/sessions/SharedSessionRepository.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 index 6cbaea417d3..c1ad7b26fc4 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -61,6 +61,7 @@ constructor( } internal var previousNotificationType: NotificationType = NotificationType.GENERAL + private var previousSessionId: String = "" init { CoroutineScope(backgroundDispatcher).launch { @@ -171,6 +172,10 @@ constructor( 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)) From 473050eb18482c8eb9cd17d80d32b54126ca376e Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 30 May 2025 06:58:46 -0700 Subject: [PATCH 17/23] Fix the profile case with clear app data (#7000) Fix the profile case with clear app data. Now the app data will clear in the setup block, instead of the measure block. This also reads the output to make sure app data has cleared before proceeding to the measure block. --- .../benchmark/sessions/StartupBenchmark.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 841cd073ba0..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 @@ -21,6 +21,7 @@ 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 @@ -48,14 +49,25 @@ class StartupBenchmark { 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 $PACKAGE_NAME") - .close() - startActivityAndWait() + .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" From ba27d567f6a799b6ec5794fe05a68fee5692e718 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 30 May 2025 07:50:52 -0700 Subject: [PATCH 18/23] Use the same tag for all logs in sessions (#7002) Use the same tag for all logs in sessions. This will make it easier to dogfood, and is consistent with Perf and Crashlytics If any context is lost by losing the tag named after the class, we can update those log lines as we run into them. A future change we can do is refactor to use the firebase common logger --- .../kotlin/com/google/firebase/sessions/EventGDTLogger.kt | 3 +-- .../kotlin/com/google/firebase/sessions/FirebaseSessions.kt | 2 +- .../google/firebase/sessions/FirebaseSessionsComponent.kt | 3 +-- .../google/firebase/sessions/FirebaseSessionsRegistrar.kt | 2 +- .../kotlin/com/google/firebase/sessions/InstallationId.kt | 3 +-- .../com/google/firebase/sessions/SessionFirelogPublisher.kt | 3 +-- .../com/google/firebase/sessions/SharedSessionRepository.kt | 5 +---- .../firebase/sessions/api/FirebaseSessionsDependencies.kt | 3 +-- .../com/google/firebase/sessions/settings/RemoteSettings.kt | 3 +-- .../com/google/firebase/sessions/settings/SettingsCache.kt | 5 +---- 10 files changed, 10 insertions(+), 22 deletions(-) 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 3830f34a1eb..59b6d9c201f 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt @@ -75,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 6eb15149cb3..e3449ee405f 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 @@ -31,6 +31,7 @@ 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.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.settings.CrashlyticsSettingsFetcher import com.google.firebase.sessions.settings.LocalOverrideSettings import com.google.firebase.sessions.settings.RemoteSettings @@ -121,8 +122,6 @@ internal interface FirebaseSessionsComponent { @Binds @Singleton fun processDataManager(impl: ProcessDataManagerImpl): ProcessDataManager companion object { - private const val TAG = "FirebaseSessions" - @Provides @Singleton fun timeProvider(): TimeProvider = TimeProviderImpl @Provides @Singleton fun uuidGenerator(): UuidGenerator = UuidGeneratorImpl 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 76c0c6330f4..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 @@ -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) 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/SessionFirelogPublisher.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionFirelogPublisher.kt index b6a3c87e1dc..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 @@ -122,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/SharedSessionRepository.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt index c1ad7b26fc4..415fd5d6fae 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -19,6 +19,7 @@ package com.google.firebase.sessions import android.util.Log import androidx.datastore.core.DataStore import com.google.firebase.annotations.concurrent.Background +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 @@ -233,8 +234,4 @@ constructor( Log.d(TAG, "No process data for ${processDataManager.myProcessName}") return true } - - private companion object { - const val TAG = "SharedSessionRepository" - } } 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..26923beee01 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()) /** 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 1079577e03c..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 @@ -21,6 +21,7 @@ import android.util.Log import androidx.annotation.VisibleForTesting 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 com.google.firebase.sessions.TimeProvider import javax.inject.Inject @@ -150,8 +151,6 @@ constructor( private fun sanitize(s: String) = s.replace(sanitizeRegex, "") private companion object { - const val TAG = "SessionConfigFetcher" - val defaultCacheDuration = 24.hours.inWholeSeconds.toInt() val sanitizeRegex = "/".toRegex() 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 1640a5c7b7a..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 @@ -20,6 +20,7 @@ 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.FirebaseSessions.Companion.TAG import com.google.firebase.sessions.TimeProvider import java.io.IOException import java.util.concurrent.atomic.AtomicReference @@ -107,8 +108,4 @@ constructor( } catch (ex: IOException) { Log.w(TAG, "Failed to remove config values: $ex") } - - private companion object { - const val TAG = "SettingsCache" - } } From a8bd90779f6a92bc508931516fed45ec7180008d Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 6 Jun 2025 10:05:46 -0700 Subject: [PATCH 19/23] Add more trace buttons to sessions test app (#7014) Add more trace buttons to sessions test app to match the detailed test cases easier --- .../testing/sessions/FirstFragment.kt | 27 ++++++++++++++++++- .../src/main/res/layout/fragment_first.xml | 19 ++++++++++++- .../test-app/src/main/res/values/strings.xml | 2 ++ .../test-app/test-app.gradle.kts | 2 ++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt index f5a965da7d4..c151b56a68f 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 @@ -30,7 +30,10 @@ 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.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()}") 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" /> +