-
Notifications
You must be signed in to change notification settings - Fork 625
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
Changes from all commits
f43248d
24ab502
8e23f15
6b7fbeb
584ccf8
9e67118
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
mrober marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return dependencies.getOrElse(subscriberName) { | ||
throw IllegalStateException( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
"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) | ||
mrober marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 | ||
} |
Uh oh!
There was an error while loading. Please reload this page.