diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt index b0bb9bca4c2..ed0d485c6de 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessions.kt @@ -38,14 +38,15 @@ constructor( private val firebaseApp: FirebaseApp, private val settings: SessionsSettings, @Background backgroundDispatcher: CoroutineContext, - sessionsActivityLifecycleCallbacks: SessionsActivityLifecycleCallbacks, + sessionsActivityLifecycleCallbacks: SharedSessionRepository, + sessionsFallbackActivityLifecycleCallbacks: SessionsFallbackActivityLifecycleCallbacks, ) { - init { Log.d(TAG, "Initializing Firebase Sessions SDK.") val appContext = firebaseApp.applicationContext.applicationContext if (appContext is Application) { - appContext.registerActivityLifecycleCallbacks(sessionsActivityLifecycleCallbacks) + SessionInitiator.lifecycleClient = sessionsActivityLifecycleCallbacks + appContext.registerActivityLifecycleCallbacks(SessionInitiator) CoroutineScope(backgroundDispatcher).launch { val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers() @@ -55,14 +56,15 @@ constructor( settings.updateSettings() if (!settings.sessionsEnabled) { Log.d(TAG, "Sessions SDK disabled. Not listening to lifecycle events.") - } else { - firebaseApp.addLifecycleEventListener { _, _ -> - // Log.w( - // TAG, - // "FirebaseApp instance deleted. Sessions library will stop collecting data.", - // ) - // TODO(mrober): Clean up on firebase app delete - } + sessionsActivityLifecycleCallbacks.unregister() + SessionInitiator.lifecycleClient = sessionsFallbackActivityLifecycleCallbacks + } + firebaseApp.addLifecycleEventListener { _, _ -> + // Log.w( + // TAG, + // "FirebaseApp instance deleted. Sessions library will stop collecting data.", + // ) + // TODO(mrober): Clean up on firebase app delete } } } diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt index bdeb73af736..3b6fe179974 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/FirebaseSessionsComponent.kt @@ -114,10 +114,6 @@ internal interface FirebaseSessionsComponent { @Binds @Singleton fun settingsCache(impl: SettingsCacheImpl): SettingsCache - @Binds - @Singleton - fun sharedSessionRepository(impl: SharedSessionRepositoryImpl): SharedSessionRepository - companion object { private const val TAG = "FirebaseSessions" diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionInitiator.kt similarity index 61% rename from firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt rename to firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionInitiator.kt index 8b817316066..3f6b75ced5b 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsActivityLifecycleCallbacks.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionInitiator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * 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. @@ -19,22 +19,31 @@ package com.google.firebase.sessions import android.app.Activity import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle -import javax.inject.Inject -import javax.inject.Singleton -/** - * Lifecycle callbacks that will inform the [SharedSessionRepository] whenever an [Activity] in this - * application process goes foreground or background. - */ -@Singleton -internal class SessionsActivityLifecycleCallbacks -@Inject -constructor(private val sharedSessionRepository: SharedSessionRepository) : - ActivityLifecycleCallbacks { - - override fun onActivityResumed(activity: Activity) = sharedSessionRepository.appForeground() +internal interface SessionLifecycleClient { + var localSessionData: SessionData + fun appForegrounded() + fun appBackgrounded() + fun unregister() = Unit +} - override fun onActivityPaused(activity: Activity) = sharedSessionRepository.appBackground() +internal object SessionInitiator : ActivityLifecycleCallbacks { + var currentLocalSession: SessionDetails? = null + get() { + return lifecycleClient?.localSessionData?.sessionDetails + } + var lifecycleClient: SessionLifecycleClient? = null + set(lifecycleClient) { + field = lifecycleClient + } + + override fun onActivityResumed(activity: Activity) { + lifecycleClient?.appForegrounded() + } + + override fun onActivityPaused(activity: Activity) { + lifecycleClient?.appBackgrounded() + } override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsFallbackActivityLifecycleCallbacks.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsFallbackActivityLifecycleCallbacks.kt new file mode 100644 index 00000000000..44e9ab654ad --- /dev/null +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SessionsFallbackActivityLifecycleCallbacks.kt @@ -0,0 +1,87 @@ +/* + * 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.util.Log +import com.google.firebase.annotations.concurrent.Background +import com.google.firebase.sessions.api.FirebaseSessionsDependencies +import com.google.firebase.sessions.api.SessionSubscriber +import com.google.firebase.sessions.settings.SessionsSettings +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * This is the fallback module for datastore implementation (SharedSessionRepository). We are + * fallback to pre multi-process support behavior. + */ +@Singleton +internal class SessionsFallbackActivityLifecycleCallbacks +@Inject +constructor( + private val sessionsSettings: SessionsSettings, + private val sessionGenerator: SessionGenerator, + private val timeProvider: TimeProvider, + @Background private val backgroundDispatcher: CoroutineContext, +) : SessionLifecycleClient { + + override var localSessionData: SessionData = + SessionData( + sessionDetails = sessionGenerator.generateNewSession(null), + timeProvider.currentTime() + ) + + init { + notifySubscribers(localSessionData.sessionDetails.sessionId) + } + + override fun appBackgrounded() { + localSessionData = localSessionData.copy(backgroundTime = timeProvider.currentTime()) + } + + override fun appForegrounded() { + if (shouldInitiateNewSession(localSessionData)) { + val newSessionDetails = sessionGenerator.generateNewSession(localSessionData.sessionDetails) + localSessionData = localSessionData.copy(sessionDetails = newSessionDetails) + notifySubscribers(localSessionData.sessionDetails.sessionId) + } + } + + private fun notifySubscribers(sessionId: String) { + CoroutineScope(backgroundDispatcher).launch { + // Only notify subscriber for session change, not send to event to firelog + FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> + subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) + Log.d( + TAG, + "Notified ${subscriber.sessionSubscriberName} of new session $sessionId with fallback mode" + ) + } + } + } + + private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { + val interval = timeProvider.currentTime() - sessionData.backgroundTime + return interval > sessionsSettings.sessionRestartTimeout + } + + private companion object { + const val TAG = "SessionsFallbackALC" + } +} diff --git a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt index fee1acd737e..34be95181e4 100644 --- a/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt +++ b/firebase-sessions/src/main/kotlin/com/google/firebase/sessions/SharedSessionRepository.kt @@ -27,17 +27,13 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.launch -/** Repository to persist session data to be shared between all app processes. */ -internal interface SharedSessionRepository { - fun appBackground() - - fun appForeground() -} - @Singleton -internal class SharedSessionRepositoryImpl +internal class SharedSessionRepository @Inject constructor( private val sessionsSettings: SessionsSettings, @@ -46,26 +42,29 @@ constructor( private val timeProvider: TimeProvider, private val sessionDataStore: DataStore, @Background private val backgroundDispatcher: CoroutineContext, -) : SharedSessionRepository { +) : SessionLifecycleClient { /** Local copy of the session data. Can get out of sync, must be double-checked in datastore. */ - private lateinit var localSessionData: SessionData + override lateinit var localSessionData: SessionData + + private var jobForCancel: Job? = null init { - CoroutineScope(backgroundDispatcher).launch { - sessionDataStore.data.collect { sessionData -> - localSessionData = sessionData - val sessionId = sessionData.sessionDetails.sessionId + jobForCancel = + CoroutineScope(backgroundDispatcher).launch { + sessionDataStore.data.cancellable().collect { sessionData -> + localSessionData = sessionData + val sessionId = sessionData.sessionDetails.sessionId - FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> - // Notify subscribers, regardless of sampling and data collection state - subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) - Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId") + FirebaseSessionsDependencies.getRegisteredSubscribers().values.forEach { subscriber -> + // Notify subscribers, regardless of sampling and data collection state + subscriber.onSessionChanged(SessionSubscriber.SessionDetails(sessionId)) + Log.d(TAG, "Notified ${subscriber.sessionSubscriberName} of new session $sessionId") + } } } - } } - override fun appBackground() { + override fun appBackgrounded() { if (!::localSessionData.isInitialized) { Log.d(TAG, "App backgrounded, but local SessionData not initialized") return @@ -81,7 +80,7 @@ constructor( } } - override fun appForeground() { + override fun appForegrounded() { if (!::localSessionData.isInitialized) { Log.d(TAG, "App foregrounded, but local SessionData not initialized") return @@ -105,6 +104,10 @@ constructor( } } + override fun unregister() { + jobForCancel?.cancel("Datastore turned off, stop flow") + } + private fun shouldInitiateNewSession(sessionData: SessionData): Boolean { val interval = timeProvider.currentTime() - sessionData.backgroundTime return interval > sessionsSettings.sessionRestartTimeout