Skip to content

Commit fa24599

Browse files
committed
Implement new cold app start detection heuristic
1 parent c24f04a commit fa24599

12 files changed

+410
-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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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() ?: mapOf(myProcessName to ProcessData(Process.myPid(), myUuid))
82+
83+
/**
84+
* Returns true if the process is stale, meaning the persisted process data does not match the
85+
* running process details.
86+
*/
87+
private fun isProcessStale(
88+
runningProcessDetails: ProcessDetails,
89+
persistedProcessData: ProcessData,
90+
): Boolean =
91+
if (myProcessName == runningProcessDetails.processName) {
92+
runningProcessDetails.pid != persistedProcessData.pid || myUuid != persistedProcessData.uuid
93+
} else {
94+
runningProcessDetails.pid != persistedProcessData.pid
95+
}
96+
}

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: 19 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
}
@@ -153,17 +155,26 @@ constructor(
153155
"Notified ${subscriber.sessionSubscriberName} of new session $sessionId"
154156
NotificationType.FALLBACK ->
155157
"Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId"
156-
}
158+
},
157159
)
158160
}
159161
}
160162

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

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 as MY_UUID
26+
import com.google.firebase.sessions.testing.FakeUuidGenerator.Companion.UUID_2 as OTHER_UUID
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(MY_UUID))
37+
38+
val coldStart =
39+
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID)))
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(MY_UUID))
50+
51+
val coldStart =
52+
processDataManager.isColdStart(
53+
mapOf(
54+
MY_PROCESS_NAME to ProcessData(MY_PID, MY_UUID),
55+
OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID),
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(MY_UUID))
66+
67+
val coldStart =
68+
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID)))
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(MY_UUID))
77+
78+
val coldStart =
79+
processDataManager.isColdStart(mapOf(MY_PROCESS_NAME to ProcessData(MY_PID, OTHER_UUID)))
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(MY_UUID))
91+
92+
val coldStart =
93+
processDataManager.isColdStart(
94+
mapOf(
95+
MY_PROCESS_NAME to ProcessData(OTHER_PID, MY_UUID),
96+
OTHER_PROCESS_NAME to ProcessData(OTHER_PID, OTHER_UUID),
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)