Skip to content

Commit a27258d

Browse files
authored
Merge branch 'sessions-sharedrepo' into mrober/appdelete
2 parents 9585c44 + d14ecab commit a27258d

File tree

3 files changed

+267
-23
lines changed

3 files changed

+267
-23
lines changed

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import kotlinx.serialization.json.Json
2727

2828
/** Session data to be persisted. */
2929
@Serializable
30-
internal data class SessionData(val sessionDetails: SessionDetails, val backgroundTime: Time)
30+
internal data class SessionData(
31+
val sessionDetails: SessionDetails,
32+
val backgroundTime: Time? = null
33+
)
3134

3235
/** DataStore json [Serializer] for [SessionData]. */
3336
@Singleton
@@ -38,11 +41,7 @@ constructor(
3841
private val timeProvider: TimeProvider,
3942
) : Serializer<SessionData> {
4043
override val defaultValue: SessionData
41-
get() =
42-
SessionData(
43-
sessionDetails = sessionGenerator.generateNewSession(currentSession = null),
44-
backgroundTime = timeProvider.currentTime(),
45-
)
44+
get() = SessionData(sessionGenerator.generateNewSession(currentSession = null))
4645

4746
override suspend fun readFrom(input: InputStream): SessionData =
4847
try {

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

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import kotlin.coroutines.CoroutineContext
2929
import kotlinx.coroutines.CoroutineScope
3030
import kotlinx.coroutines.flow.catch
3131
import kotlinx.coroutines.launch
32-
import org.jetbrains.annotations.VisibleForTesting
3332

3433
/** Repository to persist session data to be shared between all app processes. */
3534
internal interface SharedSessionRepository {
@@ -50,26 +49,26 @@ constructor(
5049
@Background private val backgroundDispatcher: CoroutineContext,
5150
) : SharedSessionRepository {
5251
/** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */
53-
@VisibleForTesting lateinit var localSessionData: SessionData
52+
internal lateinit var localSessionData: SessionData
5453

5554
/**
5655
* Either notify the subscribers with general multi-process supported session or fallback local
5756
* session
5857
*/
59-
private enum class NotificationType {
58+
internal enum class NotificationType {
6059
GENERAL,
6160
FALLBACK
6261
}
62+
internal var previousNotificationType: NotificationType = NotificationType.GENERAL
6363

6464
init {
65-
println("session repo init")
6665
CoroutineScope(backgroundDispatcher).launch {
6766
sessionDataStore.data
6867
.catch {
6968
val newSession =
7069
SessionData(
7170
sessionDetails = sessionGenerator.generateNewSession(null),
72-
backgroundTime = timeProvider.currentTime()
71+
backgroundTime = null
7372
)
7473
Log.d(
7574
TAG,
@@ -123,15 +122,16 @@ constructor(
123122
val newSessionDetails =
124123
sessionGenerator.generateNewSession(sessionData.sessionDetails)
125124
sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails)
126-
currentSessionData.copy(sessionDetails = newSessionDetails)
125+
currentSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null)
127126
} else {
128127
currentSessionData
129128
}
130129
}
131130
} catch (ex: Exception) {
132131
Log.d(TAG, "App appForegrounded, failed to update data. Message: ${ex.message}")
133132
val newSessionDetails = sessionGenerator.generateNewSession(sessionData.sessionDetails)
134-
localSessionData = localSessionData.copy(sessionDetails = newSessionDetails)
133+
localSessionData =
134+
localSessionData.copy(sessionDetails = newSessionDetails, backgroundTime = null)
135135
sessionFirelogPublisher.mayLogSession(sessionDetails = newSessionDetails)
136136

137137
val sessionId = newSessionDetails.sessionId
@@ -142,24 +142,29 @@ constructor(
142142
}
143143

144144
private suspend fun notifySubscribers(sessionId: String, type: NotificationType) {
145+
previousNotificationType = type
145146
FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber ->
146147
// Notify subscribers, regardless of sampling and data collection state
147148
subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId))
148-
when (type) {
149-
NotificationType.GENERAL ->
150-
Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId")
151-
NotificationType.FALLBACK ->
152-
Log.d(
153-
TAG,
149+
Log.d(
150+
TAG,
151+
when (type) {
152+
NotificationType.GENERAL ->
153+
"Notified ${subscriber.sessionSubscriberName} of new session $sessionId"
154+
NotificationType.FALLBACK ->
154155
"Notified ${subscriber.sessionSubscriberName} of new fallback session $sessionId"
155-
)
156-
}
156+
}
157+
)
157158
}
158159
}
159160

160161
private fun shouldInitiateNewSession(sessionData: SessionData): Boolean {
161-
val interval = timeProvider.currentTime() - sessionData.backgroundTime
162-
return interval > sessionsSettings.sessionRestartTimeout
162+
sessionData.backgroundTime?.let {
163+
val interval = timeProvider.currentTime() - it
164+
return interval > sessionsSettings.sessionRestartTimeout
165+
}
166+
Log.d(TAG, "No process has backgrounded yet, should not change the session.")
167+
return false
163168
}
164169

165170
private companion object {
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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.concurrent.TestOnlyExecutors
23+
import com.google.firebase.sessions.SessionGeneratorTest.Companion.SESSION_ID_1
24+
import com.google.firebase.sessions.SessionGeneratorTest.Companion.SESSION_ID_2
25+
import com.google.firebase.sessions.settings.SessionsSettings
26+
import com.google.firebase.sessions.testing.FakeDataStore
27+
import com.google.firebase.sessions.testing.FakeEventGDTLogger
28+
import com.google.firebase.sessions.testing.FakeFirebaseApp
29+
import com.google.firebase.sessions.testing.FakeFirebaseInstallations
30+
import com.google.firebase.sessions.testing.FakeSettingsProvider
31+
import com.google.firebase.sessions.testing.FakeTimeProvider
32+
import com.google.firebase.sessions.testing.FakeUuidGenerator
33+
import kotlin.time.Duration.Companion.hours
34+
import kotlinx.coroutines.ExperimentalCoroutinesApi
35+
import kotlinx.coroutines.asCoroutineDispatcher
36+
import kotlinx.coroutines.test.runCurrent
37+
import kotlinx.coroutines.test.runTest
38+
import org.junit.After
39+
import org.junit.Test
40+
import org.junit.runner.RunWith
41+
42+
@OptIn(ExperimentalCoroutinesApi::class)
43+
@RunWith(AndroidJUnit4::class)
44+
class SharedSessionRepositoryTest {
45+
private val fakeFirebaseApp = FakeFirebaseApp()
46+
private val fakeEventGDTLogger = FakeEventGDTLogger()
47+
private val firebaseInstallations = FakeFirebaseInstallations("FaKeFiD", "FakeAuthToken")
48+
private var fakeTimeProvider = FakeTimeProvider()
49+
private val sessionGenerator = SessionGenerator(fakeTimeProvider, FakeUuidGenerator())
50+
private var localSettingsProvider = FakeSettingsProvider(true, null, 100.0)
51+
private var remoteSettingsProvider = FakeSettingsProvider(true, null, 100.0)
52+
private var sessionsSettings = SessionsSettings(localSettingsProvider, remoteSettingsProvider)
53+
54+
@After
55+
fun cleanUp() {
56+
FirebaseApp.clearInstancesForTest()
57+
}
58+
59+
@Test
60+
fun initSharedSessionRepo_readFromDatastore() = runTest {
61+
val publisher =
62+
SessionFirelogPublisherImpl(
63+
fakeFirebaseApp.firebaseApp,
64+
firebaseInstallations,
65+
sessionsSettings,
66+
eventGDTLogger = fakeEventGDTLogger,
67+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext,
68+
)
69+
val fakeDataStore =
70+
FakeDataStore<SessionData>(
71+
SessionData(
72+
SessionDetails(
73+
SESSION_ID_INIT,
74+
SESSION_ID_INIT,
75+
0,
76+
fakeTimeProvider.currentTime().ms,
77+
),
78+
fakeTimeProvider.currentTime(),
79+
)
80+
)
81+
val sharedSessionRepository =
82+
SharedSessionRepositoryImpl(
83+
sessionsSettings = sessionsSettings,
84+
sessionGenerator = sessionGenerator,
85+
sessionFirelogPublisher = publisher,
86+
timeProvider = fakeTimeProvider,
87+
sessionDataStore = fakeDataStore,
88+
backgroundDispatcher =
89+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext
90+
)
91+
runCurrent()
92+
fakeDataStore.close()
93+
assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId)
94+
.isEqualTo(SESSION_ID_INIT)
95+
}
96+
97+
@Test
98+
fun initSharedSessionRepo_initException() = runTest {
99+
val sessionFirelogPublisher =
100+
SessionFirelogPublisherImpl(
101+
fakeFirebaseApp.firebaseApp,
102+
firebaseInstallations,
103+
sessionsSettings,
104+
eventGDTLogger = fakeEventGDTLogger,
105+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext,
106+
)
107+
val fakeDataStore =
108+
FakeDataStore<SessionData>(
109+
SessionData(
110+
SessionDetails(
111+
SESSION_ID_INIT,
112+
SESSION_ID_INIT,
113+
0,
114+
fakeTimeProvider.currentTime().ms,
115+
),
116+
fakeTimeProvider.currentTime(),
117+
),
118+
IllegalArgumentException("Datastore init failed")
119+
)
120+
val sharedSessionRepository =
121+
SharedSessionRepositoryImpl(
122+
sessionsSettings,
123+
sessionGenerator,
124+
sessionFirelogPublisher,
125+
fakeTimeProvider,
126+
fakeDataStore,
127+
backgroundDispatcher =
128+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext
129+
)
130+
runCurrent()
131+
fakeDataStore.close()
132+
assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId)
133+
.isEqualTo(SESSION_ID_1)
134+
}
135+
136+
@Test
137+
fun appForegroundGenerateNewSession_updateSuccess() = runTest {
138+
val sessionFirelogPublisher =
139+
SessionFirelogPublisherImpl(
140+
fakeFirebaseApp.firebaseApp,
141+
firebaseInstallations,
142+
sessionsSettings,
143+
eventGDTLogger = fakeEventGDTLogger,
144+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext,
145+
)
146+
val fakeDataStore =
147+
FakeDataStore<SessionData>(
148+
SessionData(
149+
SessionDetails(
150+
SESSION_ID_INIT,
151+
SESSION_ID_INIT,
152+
0,
153+
fakeTimeProvider.currentTime().ms,
154+
),
155+
fakeTimeProvider.currentTime(),
156+
)
157+
)
158+
val sharedSessionRepository =
159+
SharedSessionRepositoryImpl(
160+
sessionsSettings,
161+
sessionGenerator,
162+
sessionFirelogPublisher,
163+
fakeTimeProvider,
164+
fakeDataStore,
165+
backgroundDispatcher =
166+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext
167+
)
168+
runCurrent()
169+
170+
fakeTimeProvider.addInterval(20.hours)
171+
sharedSessionRepository.appForeground()
172+
runCurrent()
173+
174+
assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId)
175+
.isEqualTo(SESSION_ID_1)
176+
assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull()
177+
assertThat(sharedSessionRepository.previousNotificationType)
178+
.isEqualTo(SharedSessionRepositoryImpl.NotificationType.GENERAL)
179+
fakeDataStore.close()
180+
}
181+
182+
@Test
183+
fun appForegroundGenerateNewSession_updateFail() = runTest {
184+
val sessionFirelogPublisher =
185+
SessionFirelogPublisherImpl(
186+
fakeFirebaseApp.firebaseApp,
187+
firebaseInstallations,
188+
sessionsSettings,
189+
eventGDTLogger = fakeEventGDTLogger,
190+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext,
191+
)
192+
val fakeDataStore =
193+
FakeDataStore<SessionData>(
194+
SessionData(
195+
SessionDetails(
196+
SESSION_ID_INIT,
197+
SESSION_ID_INIT,
198+
0,
199+
fakeTimeProvider.currentTime().ms,
200+
),
201+
fakeTimeProvider.currentTime(),
202+
),
203+
IllegalArgumentException("Datastore init failed")
204+
)
205+
val sharedSessionRepository =
206+
SharedSessionRepositoryImpl(
207+
sessionsSettings,
208+
sessionGenerator,
209+
sessionFirelogPublisher,
210+
fakeTimeProvider,
211+
fakeDataStore,
212+
backgroundDispatcher =
213+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext
214+
)
215+
runCurrent()
216+
217+
// set background time first
218+
fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed"))
219+
sharedSessionRepository.appBackground()
220+
runCurrent()
221+
222+
// foreground update session
223+
fakeTimeProvider.addInterval(20.hours)
224+
fakeDataStore.throwOnNextUpdateData(IllegalArgumentException("Datastore update failed"))
225+
sharedSessionRepository.appForeground()
226+
runCurrent()
227+
228+
// session_2 here because session_1 is failed when try to init datastore
229+
assertThat(sharedSessionRepository.localSessionData.sessionDetails.sessionId)
230+
.isEqualTo(SESSION_ID_2)
231+
assertThat(sharedSessionRepository.localSessionData.backgroundTime).isNull()
232+
assertThat(sharedSessionRepository.previousNotificationType)
233+
.isEqualTo(SharedSessionRepositoryImpl.NotificationType.FALLBACK)
234+
fakeDataStore.close()
235+
}
236+
237+
companion object {
238+
const val SESSION_ID_INIT = "12345678901234546677960"
239+
}
240+
}

0 commit comments

Comments
 (0)