Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions firebase-sessions/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion firebase-sessions/firebase-sessions.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ firebaseLibrary {

testLab.enabled = true
publishJavadoc = false
releaseNotes { enabled.set(false) }

releaseNotes {
enabled = false
hasKTX = false
}
}

android {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, ProcessData>): 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<String, ProcessData>?): Map<String, ProcessData>

/** 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<String, ProcessData>): 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<String, ProcessData>?
): Map<String, ProcessData> =
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ProcessData>? = 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<SessionData> {
constructor(private val sessionGenerator: SessionGenerator) : Serializer<SessionData> {
override val defaultValue: SessionData
get() = SessionData(sessionGenerator.generateNewSession(currentSession = null))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ constructor(
private val sessionFirelogPublisher: SessionFirelogPublisher,
private val timeProvider: TimeProvider,
private val sessionDataStore: DataStore<SessionData>,
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. */
Expand All @@ -57,8 +58,9 @@ constructor(
*/
internal enum class NotificationType {
GENERAL,
FALLBACK
FALLBACK,
}

internal var previousNotificationType: NotificationType = NotificationType.GENERAL

init {
Expand All @@ -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)
}
Expand Down Expand Up @@ -153,17 +155,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) {
// Passed session restart timeout, so should initiate a new session
return true
}
}
Log.d(TAG, "No process has backgrounded yet, should not change the session.")

sessionData.processDataMap?.let { processDataMap ->
// Has not passed session restart timeout, so check for cold app start
return processDataManager.isColdStart(processDataMap)
}

// No process has backgrounded yet and no process mapping, should not change the session
return false
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 as MY_UUID
import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2 as OTHER_UUID
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
internal class ProcessDataManagerTest {
@Test
fun isColdStart_myProcess() {
val appContext = FakeFirebaseApp().firebaseApp.applicationContext
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID))

val coldStart =
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID)))

assertThat(coldStart).isFalse()
}

fun isColdStart_myProcessCurrent_otherProcessCurrent() {
val appContext =
FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo))
.firebaseApp
.applicationContext
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID))

val coldStart =
processDataManager.isColdStart(
mapOf(
MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID),
OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID),
)
)

assertThat(coldStart).isFalse()
}

@Test
fun isColdStart_staleProcessPid() {
val appContext = FakeFirebaseApp().firebaseApp.applicationContext
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID))

val coldStart =
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID)))

assertThat(coldStart).isTrue()
}

@Test
fun isColdStart_staleProcessUuid() {
val appContext = FakeFirebaseApp().firebaseApp.applicationContext
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID))

val coldStart =
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, OTHER_UUID)))

assertThat(coldStart).isTrue()
}

@Test
fun isColdStart_myProcessStale_otherProcessCurrent() {
val appContext =
FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo))
.firebaseApp
.applicationContext
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(MY_UUID))

val coldStart =
processDataManager.isColdStart(
mapOf(
MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID),
OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID),
)
)

assertThat(coldStart).isFalse()
}

@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)
}
}
Loading
Loading