Skip to content

Implement new cold app start detection heuristic #6950

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 9, 2025
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