Skip to content

Commit 97de840

Browse files
authored
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
1 parent c24f04a commit 97de840

File tree

12 files changed

+412
-63
lines changed

12 files changed

+412
-63
lines changed

firebase-sessions/CHANGELOG.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
# Unreleased
22
* [changed] Use multi-process DataStore instead of Preferences DataStore
3+
* [changed] Update the heuristic to detect cold app starts
34

45
# 2.1.1
56
* [unchanged] Updated to keep SDK versions aligned.
67

7-
8-
## Kotlin
9-
The Kotlin extensions library transitively includes the updated
10-
`firebase-sessions` library. The Kotlin extensions library has no additional
11-
updates.
12-
138
# 2.1.0
149
* [changed] Add warning for known issue b/328687152
1510
* [changed] Use Dagger for dependency injection

firebase-sessions/firebase-sessions.gradle.kts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ firebaseLibrary {
2929

3030
testLab.enabled = true
3131
publishJavadoc = false
32-
releaseNotes { enabled.set(false) }
32+
33+
releaseNotes {
34+
enabled = false
35+
hasKTX = false
36+
}
3337
}
3438

3539
android {

firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ internal interface FirebaseSessionsComponent {
118118
@Singleton
119119
fun sharedSessionRepository(impl: SharedSessionRepositoryImpl): SharedSessionRepository
120120

121+
@Binds @Singleton fun processDataManager(impl: ProcessDataManagerImpl): ProcessDataManager
122+
121123
companion object {
122124
private const val TAG = "FirebaseSessions"
123125

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.sessions
18+
19+
import android.content.Context
20+
import android.os.Process
21+
import javax.inject.Inject
22+
import javax.inject.Singleton
23+
24+
/** Manage process data, used for detecting cold app starts. */
25+
internal interface ProcessDataManager {
26+
/** An in-memory uuid to uniquely identify this instance of this process. */
27+
val myUuid: String
28+
29+
/** Checks if this is a cold app start, meaning all processes in the mapping table are stale. */
30+
fun isColdStart(processDataMap: Map<String, ProcessData>): Boolean
31+
32+
/** Call to notify the process data manager that a session has been generated. */
33+
fun onSessionGenerated()
34+
35+
/** Update the mapping of the current processes with data about this process. */
36+
fun updateProcessDataMap(processDataMap: Map<String, ProcessData>?): Map<String, ProcessData>
37+
38+
/** Generate a new mapping of process data with the current process only. */
39+
fun generateProcessDataMap() = updateProcessDataMap(mapOf())
40+
}
41+
42+
/** Manage process data, used for detecting cold app starts. */
43+
@Singleton
44+
internal class ProcessDataManagerImpl
45+
@Inject
46+
constructor(private val appContext: Context, private val uuidGenerator: UuidGenerator) :
47+
ProcessDataManager {
48+
override val myUuid: String by lazy { uuidGenerator.next().toString() }
49+
50+
private val myProcessName: String by lazy {
51+
ProcessDetailsProvider.getCurrentProcessDetails(appContext).processName
52+
}
53+
54+
private var hasGeneratedSession: Boolean = false
55+
56+
override fun isColdStart(processDataMap: Map<String, ProcessData>): Boolean {
57+
if (hasGeneratedSession) {
58+
// This process has been notified that a session was generated, so cannot be a cold start
59+
return false
60+
}
61+
62+
return ProcessDetailsProvider.getAppProcessDetails(appContext)
63+
.mapNotNull { processDetails ->
64+
processDataMap[processDetails.processName]?.let { processData ->
65+
Pair(processDetails, processData)
66+
}
67+
}
68+
.all { (processDetails, processData) -> isProcessStale(processDetails, processData) }
69+
}
70+
71+
override fun onSessionGenerated() {
72+
hasGeneratedSession = true
73+
}
74+
75+
override fun updateProcessDataMap(
76+
processDataMap: Map<String, ProcessData>?
77+
): Map<String, ProcessData> =
78+
processDataMap
79+
?.toMutableMap()
80+
?.apply { this[myProcessName] = ProcessData(Process.myPid(), myUuid) }
81+
?.toMap()
82+
?: mapOf(myProcessName to ProcessData(Process.myPid(), myUuid))
83+
84+
/**
85+
* Returns true if the process is stale, meaning the persisted process data does not match the
86+
* running process details.
87+
*/
88+
private fun isProcessStale(
89+
runningProcessDetails: ProcessDetails,
90+
persistedProcessData: ProcessData,
91+
): Boolean =
92+
if (myProcessName == runningProcessDetails.processName) {
93+
runningProcessDetails.pid != persistedProcessData.pid || myUuid != persistedProcessData.uuid
94+
} else {
95+
runningProcessDetails.pid != persistedProcessData.pid
96+
}
97+
}

firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionData.kt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,18 @@ import kotlinx.serialization.json.Json
2929
@Serializable
3030
internal data class SessionData(
3131
val sessionDetails: SessionDetails,
32-
val backgroundTime: Time? = null
32+
val backgroundTime: Time? = null,
33+
val processDataMap: Map<String, ProcessData>? = null,
3334
)
3435

36+
/** Data about a process, for persistence. */
37+
@Serializable internal data class ProcessData(val pid: Int, val uuid: String)
38+
3539
/** DataStore json [Serializer] for [SessionData]. */
3640
@Singleton
3741
internal class SessionDataSerializer
3842
@Inject
39-
constructor(
40-
private val sessionGenerator: SessionGenerator,
41-
private val timeProvider: TimeProvider,
42-
) : Serializer<SessionData> {
43+
constructor(private val sessionGenerator: SessionGenerator) : Serializer<SessionData> {
4344
override val defaultValue: SessionData
4445
get() = SessionData(sessionGenerator.generateNewSession(currentSession = null))
4546

firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ constructor(
4646
private val sessionFirelogPublisher: SessionFirelogPublisher,
4747
private val timeProvider: TimeProvider,
4848
private val sessionDataStore: DataStore<SessionData>,
49+
private val processDataManager: ProcessDataManager,
4950
@Background private val backgroundDispatcher: CoroutineContext,
5051
) : SharedSessionRepository {
5152
/** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */
@@ -57,8 +58,9 @@ constructor(
5758
*/
5859
internal enum class NotificationType {
5960
GENERAL,
60-
FALLBACK
61+
FALLBACK,
6162
}
63+
6264
internal var previousNotificationType: NotificationType = NotificationType.GENERAL
6365

6466
init {
@@ -68,11 +70,11 @@ constructor(
6870
val newSession =
6971
SessionData(
7072
sessionDetails = sessionGenerator.generateNewSession(null),
71-
backgroundTime = null
73+
backgroundTime = null,
7274
)
7375
Log.d(
7476
TAG,
75-
"Init session datastore failed with exception message: ${it.message}. Emit fallback session ${newSession.sessionDetails.sessionId}"
77+
"Init session datastore failed with exception message: ${it.message}. Emit fallback session ${newSession.sessionDetails.sessionId}",
7678
)
7779
emit(newSession)
7880
}
@@ -122,6 +124,7 @@ constructor(
122124
val newSessionDetails =
123125
sessionGenerator.generateNewSession(sessionData.sessionDetails)
124126
sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails)
127+
processDataManager.onSessionGenerated()
125128
currentSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null)
126129
} else {
127130
currentSessionData
@@ -153,17 +156,26 @@ constructor(
153156
"Notified ${subscriber.sessionSubscriberName} of new session $sessionId"
154157
NotificationType.FALLBACK ->
155158
"Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId"
156-
}
159+
},
157160
)
158161
}
159162
}
160163

161164
private fun shouldInitiateNewSession(sessionData: SessionData): Boolean {
162-
sessionData.backgroundTime?.let {
163-
val interval = timeProvider.currentTime() - it
164-
return interval > sessionsSettings.sessionRestartTimeout
165+
sessionData.backgroundTime?.let { backgroundTime ->
166+
val interval = timeProvider.currentTime() - backgroundTime
167+
if (interval > sessionsSettings.sessionRestartTimeout) {
168+
Log.d(TAG, "Passed session restart timeout, so initiate a new session")
169+
return true
170+
}
165171
}
166-
Log.d(TAG, "No process has backgrounded yet, should not change the session.")
172+
173+
sessionData.processDataMap?.let { processDataMap ->
174+
Log.d(TAG, "Has not passed session restart timeout, so check for cold app start")
175+
return processDataManager.isColdStart(processDataMap)
176+
}
177+
178+
Log.d(TAG, "No process has backgrounded yet and no process data, should not change the session")
167179
return false
168180
}
169181

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.sessions
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.google.common.truth.Truth.assertThat
21+
import com.google.firebase.FirebaseApp
22+
import com.google.firebase.sessions.testing.FakeFirebaseApp
23+
import com.google.firebase.sessions.testing.FakeRunningAppProcessInfo
24+
import com.google.firebase.sessions.testing.FakeUuidGenerator
25+
import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_1
26+
import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2
27+
import org.junit.After
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
@RunWith(AndroidJUnit4::class)
32+
internal class ProcessDataManagerTest {
33+
@Test
34+
fun isColdStart_myProcess() {
35+
val appContext = FakeFirebaseApp().firebaseApp.applicationContext
36+
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1))
37+
38+
val coldStart =
39+
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1)))
40+
41+
assertThat(coldStart).isFalse()
42+
}
43+
44+
fun isColdStart_myProcessCurrent_otherProcessCurrent() {
45+
val appContext =
46+
FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo))
47+
.firebaseApp
48+
.applicationContext
49+
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1))
50+
51+
val coldStart =
52+
processDataManager.isColdStart(
53+
mapOf(
54+
MY_PROCESS_NAME to ProcessData(MY_PID, UUID_1),
55+
OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2),
56+
)
57+
)
58+
59+
assertThat(coldStart).isFalse()
60+
}
61+
62+
@Test
63+
fun isColdStart_staleProcessPid() {
64+
val appContext = FakeFirebaseApp().firebaseApp.applicationContext
65+
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1))
66+
67+
val coldStart =
68+
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1)))
69+
70+
assertThat(coldStart).isTrue()
71+
}
72+
73+
@Test
74+
fun isColdStart_staleProcessUuid() {
75+
val appContext = FakeFirebaseApp().firebaseApp.applicationContext
76+
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1))
77+
78+
val coldStart =
79+
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, UUID_2)))
80+
81+
assertThat(coldStart).isTrue()
82+
}
83+
84+
@Test
85+
fun isColdStart_myProcessStale_otherProcessCurrent() {
86+
val appContext =
87+
FakeFirebaseApp(processes = listOf(myProcessInfo, otherProcessInfo))
88+
.firebaseApp
89+
.applicationContext
90+
val processDataManager = ProcessDataManagerImpl(appContext, FakeUuidGenerator(UUID_1))
91+
92+
val coldStart =
93+
processDataManager.isColdStart(
94+
mapOf(
95+
MY_PROCESS_NAME to ProcessData(OTHER_PID, UUID_1),
96+
OTHER_PROCESS_NAME to ProcessData(OTHER_PID, UUID_2),
97+
)
98+
)
99+
100+
assertThat(coldStart).isFalse()
101+
}
102+
103+
@After
104+
fun cleanUp() {
105+
FirebaseApp.clearInstancesForTest()
106+
}
107+
108+
private companion object {
109+
const val MY_PROCESS_NAME = "com.google.firebase.sessions.test"
110+
const val OTHER_PROCESS_NAME = "not.my.process"
111+
112+
const val MY_PID = 0
113+
const val OTHER_PID = 4
114+
115+
val myProcessInfo = FakeRunningAppProcessInfo(pid = MY_PID, processName = MY_PROCESS_NAME)
116+
117+
val otherProcessInfo =
118+
FakeRunningAppProcessInfo(pid = OTHER_PID, processName = OTHER_PROCESS_NAME)
119+
}
120+
}

0 commit comments

Comments
 (0)