Skip to content

Add thread safe FirebaseSessionsDependencies #4983

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 6 commits into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2023 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.api

import android.util.Log
import androidx.annotation.VisibleForTesting
import java.util.Collections.synchronizedMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
* [FirebaseSessionsDependencies] determines when a dependent SDK is installed in the app. The
* Sessions SDK uses this to figure out which dependencies to wait for to getting the data
* collection state. This is thread safe.
*
* This is important because the Sessions SDK starts up before dependent SDKs.
*/
object FirebaseSessionsDependencies {
private const val TAG = "SessionsDependencies"

private val dependencies = synchronizedMap(mutableMapOf<SessionSubscriber.Name, Dependency>())

/**
* Add a subscriber as a dependency to the Sessions SDK. Every dependency must register itself, or
* the Sessions SDK will never generate a session.
*/
fun addDependency(subscriberName: SessionSubscriber.Name) {
if (dependencies.containsKey(subscriberName)) {
Log.d(TAG, "Dependency $subscriberName already added.")
return
}

// The dependency is locked until the subscriber registers itself.
dependencies[subscriberName] = Dependency(Mutex(locked = true))
}

/**
* Register and unlock the subscriber. This must be called before [getRegisteredSubscribers] can
* return.
*/
internal fun register(subscriber: SessionSubscriber) {
val subscriberName = subscriber.sessionSubscriberName
val dependency = getDependency(subscriberName)

dependency.subscriber?.run {
Log.d(TAG, "Subscriber $subscriberName already registered.")
return
}
dependency.subscriber = subscriber

// Unlock to show the subscriber has been registered, it is possible to get it now.
dependency.mutex.unlock()
}

/** Gets the subscribers safely, blocks until all the subscribers are registered. */
internal suspend fun getRegisteredSubscribers(): Map<SessionSubscriber.Name, SessionSubscriber> {
// The call to getSubscriber will never throw because the mutex guarantees it's been registered.
return dependencies.mapValues { (subscriberName, dependency) ->
dependency.mutex.withLock { getSubscriber(subscriberName) }
}
}

/** Gets the subscriber, regardless of being registered. This is exposed for testing. */
@VisibleForTesting
internal fun getSubscriber(subscriberName: SessionSubscriber.Name): SessionSubscriber {
return getDependency(subscriberName).subscriber
?: throw IllegalStateException("Subscriber $subscriberName has not been registered.")
}

/** Resets all the dependencies for testing purposes. */
@VisibleForTesting internal fun reset() = dependencies.clear()

private fun getDependency(subscriberName: SessionSubscriber.Name): Dependency {
return dependencies.getOrElse(subscriberName) {
throw IllegalStateException(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, a late nit pick: Should this throw an exception? Can we get away with a Log message? Basically when this exception occurs for a developers, there is nothing he would be able to fix it. So, thinking if this should be converted to a log message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried making this nullable, but it is pretty ugly to deal with nulls through this class. If the dependency hasn't been added then getSubscriber will end up throwing anyway, just with a less useful message. I intend this exception to help the 1P developer integrate a Firebase product with Sessions in the future.

"Cannot get dependency $subscriberName. Dependencies should be added at class load time."
)
}
}

private data class Dependency(val mutex: Mutex, var subscriber: SessionSubscriber? = null)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2023 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.api

/** [SessionSubscriber] is an interface that dependent SDKs must implement. */
interface SessionSubscriber {
/** [SessionSubscriber.Name]s are used for identifying subscribers. */
enum class Name {
CRASHLYTICS,
PERFORMANCE,
}

/** [SessionDetails] contains session data passed to subscribers whenever the session changes */
data class SessionDetails(val sessionId: String)

fun onSessionChanged(sessionDetails: SessionDetails)

val isDataCollectionEnabled: Boolean

val sessionSubscriberName: SessionSubscriber.Name
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2023 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.api

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.google.firebase.sessions.api.SessionSubscriber.Name.CRASHLYTICS
import com.google.firebase.sessions.api.SessionSubscriber.Name.PERFORMANCE
import com.google.firebase.sessions.testing.FakeSessionSubscriber
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class FirebaseSessionsDependenciesTest {
@After
fun cleanUp() {
// Reset all dependencies after each test.
FirebaseSessionsDependencies.reset()
}

@Test
fun register_dependencyAdded_canGet() {
val crashlyticsSubscriber = FakeSessionSubscriber(sessionSubscriberName = CRASHLYTICS)
FirebaseSessionsDependencies.addDependency(CRASHLYTICS)
FirebaseSessionsDependencies.register(crashlyticsSubscriber)

assertThat(FirebaseSessionsDependencies.getSubscriber(CRASHLYTICS))
.isEqualTo(crashlyticsSubscriber)
}

@Test
fun register_alreadyRegisteredSameName_ignoresSecondSubscriber() {
val firstSubscriber = FakeSessionSubscriber(sessionSubscriberName = CRASHLYTICS)
val secondSubscriber = FakeSessionSubscriber(sessionSubscriberName = CRASHLYTICS)

FirebaseSessionsDependencies.addDependency(CRASHLYTICS)

// Register the first time, no problem.
FirebaseSessionsDependencies.register(firstSubscriber)

// Attempt to register a second subscriber with the same name.
FirebaseSessionsDependencies.register(secondSubscriber)

assertThat(FirebaseSessionsDependencies.getSubscriber(CRASHLYTICS)).isEqualTo(firstSubscriber)
}

@Test
fun getSubscriber_dependencyAdded_notRegistered_throws() {
FirebaseSessionsDependencies.addDependency(PERFORMANCE)

val thrown =
assertThrows(IllegalStateException::class.java) {
FirebaseSessionsDependencies.getSubscriber(PERFORMANCE)
}

assertThat(thrown).hasMessageThat().contains("Subscriber PERFORMANCE has not been registered")
}

@Test
fun getSubscriber_notDepended_throws() {
val thrown =
assertThrows(IllegalStateException::class.java) {
// Crashlytics was never added as a dependency.
FirebaseSessionsDependencies.getSubscriber(CRASHLYTICS)
}

assertThat(thrown).hasMessageThat().contains("Cannot get dependency CRASHLYTICS")
}

@Test
fun getSubscribers_waitsForRegister(): Unit = runBlocking {
val crashlyticsSubscriber = FakeSessionSubscriber(sessionSubscriberName = CRASHLYTICS)
FirebaseSessionsDependencies.addDependency(CRASHLYTICS)

// Wait a few seconds and then register.
launch {
delay(2.seconds)
FirebaseSessionsDependencies.register(crashlyticsSubscriber)
}

// Block until the register happens.
val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers()

assertThat(subscribers).containsExactly(CRASHLYTICS, crashlyticsSubscriber)
}

@Test
fun getSubscribers_noDependencies() = runTest {
val subscribers = FirebaseSessionsDependencies.getRegisteredSubscribers()

assertThat(subscribers).isEmpty()
}

@Test(expected = TimeoutCancellationException::class)
fun getSubscribers_neverRegister_waitsForever() = runTest {
FirebaseSessionsDependencies.addDependency(CRASHLYTICS)

// The register never happens, wait until the timeout.
withTimeout(2.seconds) { FirebaseSessionsDependencies.getRegisteredSubscribers() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2023 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.testing

import com.google.firebase.sessions.api.SessionSubscriber
import com.google.firebase.sessions.api.SessionSubscriber.Name.CRASHLYTICS

/** Fake [SessionSubscriber] that can set [isDataCollectionEnabled] and [sessionSubscriberName]. */
internal class FakeSessionSubscriber(
override val isDataCollectionEnabled: Boolean = true,
override val sessionSubscriberName: SessionSubscriber.Name = CRASHLYTICS,
) : SessionSubscriber {
override fun onSessionChanged(sessionDetails: SessionSubscriber.SessionDetails) = Unit
}