mirror of
https://github.com/bitwarden/android.git
synced 2025-02-22 08:39:01 +03:00
PM-15383 PM-15381 - Show the google play review prompt (#4455)
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
This commit is contained in:
parent
843247b02d
commit
6c355ae5b7
12 changed files with 201 additions and 3 deletions
README.md
app
build.gradle.kts
src
fdroid/java/com/x8bit/bitwarden/ui/platform/manager/review
main
java/com/x8bit/bitwarden/ui
platform
vault/feature/vault
res/values
standard/java/com/x8bit/bitwarden/ui/platform/manager/review
test/java/com/x8bit/bitwarden/ui/vault/feature/vault
gradle
|
@ -132,6 +132,11 @@ The following is a list of all third-party dependencies included as part of the
|
|||
- https://github.com/firebase/firebase-android-sdk
|
||||
- Purpose: SDK for crash and non-fatal error reporting. (**NOTE:** This dependency is not included in builds distributed via F-Droid.)
|
||||
- License: Apache 2.0
|
||||
|
||||
- **Google Play Reviews**
|
||||
- https://developer.android.com/reference/com/google/android/play/core/release-notes
|
||||
- Purpose: On standard builds provide an interface to add a review for the password manager application in Google Play.
|
||||
- License: Apache 2.0
|
||||
|
||||
- **Glide**
|
||||
- https://github.com/bumptech/glide
|
||||
|
|
|
@ -268,6 +268,7 @@ dependencies {
|
|||
standardImplementation(libs.google.firebase.cloud.messaging)
|
||||
standardImplementation(platform(libs.google.firebase.bom))
|
||||
standardImplementation(libs.google.firebase.crashlytics)
|
||||
standardImplementation(libs.google.play.review)
|
||||
|
||||
testImplementation(libs.androidx.compose.ui.test)
|
||||
testImplementation(libs.google.hilt.android.testing)
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package com.x8bit.bitwarden.ui.platform.manager.review
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* No-op implementation of [AppReviewManager] for F-Droid builds.
|
||||
*/
|
||||
class AppReviewManagerImpl(
|
||||
activity: Activity,
|
||||
) : AppReviewManager {
|
||||
override fun promptForReview() = Unit
|
||||
}
|
|
@ -25,6 +25,8 @@ import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManager
|
|||
import com.x8bit.bitwarden.ui.platform.manager.nfc.NfcManagerImpl
|
||||
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManagerImpl
|
||||
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManagerImpl
|
||||
|
||||
/**
|
||||
* Helper [Composable] that wraps a [content] and provides manager classes via [CompositionLocal].
|
||||
|
@ -48,6 +50,7 @@ fun LocalManagerProvider(
|
|||
LocalBiometricsManager provides BiometricsManagerImpl(activity),
|
||||
LocalNfcManager provides NfcManagerImpl(activity),
|
||||
LocalFido2CompletionManager provides fido2CompletionManager,
|
||||
LocalAppReviewManager provides AppReviewManagerImpl(activity),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
@ -88,7 +91,17 @@ val LocalNfcManager: ProvidableCompositionLocal<NfcManager> = compositionLocalOf
|
|||
error("CompositionLocal NfcManager not present")
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to the FIDO2 completion manager throughout the app.
|
||||
*/
|
||||
val LocalFido2CompletionManager: ProvidableCompositionLocal<Fido2CompletionManager> =
|
||||
compositionLocalOf {
|
||||
error("CompositionLocal Fido2CompletionManager not present")
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to the app review manager throughout the app.
|
||||
*/
|
||||
val LocalAppReviewManager: ProvidableCompositionLocal<AppReviewManager> = compositionLocalOf {
|
||||
error("CompositionLocal AppReviewManager not present")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
package com.x8bit.bitwarden.ui.platform.manager.review
|
||||
|
||||
/**
|
||||
* Manager for prompting the user to review the app.
|
||||
*/
|
||||
interface AppReviewManager {
|
||||
|
||||
/**
|
||||
* Prompts the user to review the app.
|
||||
*/
|
||||
fun promptForReview()
|
||||
}
|
|
@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -25,9 +26,11 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher
|
||||
|
@ -52,22 +55,28 @@ import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
|
|||
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHostState
|
||||
import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalAppReviewManager
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
|
||||
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val APP_REVIEW_DELAY = 3000L
|
||||
|
||||
/**
|
||||
* The vault screen for the application.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun VaultScreen(
|
||||
viewModel: VaultViewModel = hiltViewModel(),
|
||||
|
@ -81,6 +90,7 @@ fun VaultScreen(
|
|||
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
|
||||
exitManager: ExitManager = LocalExitManager.current,
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
appReviewManager: AppReviewManager = LocalAppReviewManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
@ -92,6 +102,24 @@ fun VaultScreen(
|
|||
},
|
||||
)
|
||||
val snackbarHostState = rememberBitwardenSnackbarHostState()
|
||||
LivecycleEventEffect { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
viewModel.trySendAction(VaultAction.LifecycleResumed)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val launchPrompt = remember {
|
||||
{
|
||||
scope.launch {
|
||||
delay(APP_REVIEW_DELAY)
|
||||
appReviewManager.promptForReview()
|
||||
}
|
||||
}
|
||||
}
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen()
|
||||
|
@ -124,6 +152,9 @@ fun VaultScreen(
|
|||
}
|
||||
|
||||
is VaultEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
|
||||
VaultEvent.PromptForAppReview -> {
|
||||
launchPrompt.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) }
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
|||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
|
||||
|
@ -75,7 +76,8 @@ class VaultViewModel @Inject constructor(
|
|||
private val vaultRepository: VaultRepository,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
private val snackbarRelayManager: SnackbarRelayManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
private val reviewPromptManager: ReviewPromptManager,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
|
||||
initialState = run {
|
||||
val userState = requireNotNull(authRepository.userStateFlow.value)
|
||||
|
@ -201,6 +203,15 @@ class VaultViewModel @Inject constructor(
|
|||
is VaultAction.Internal -> handleInternalAction(action)
|
||||
VaultAction.DismissImportActionCard -> handleDismissImportActionCard()
|
||||
VaultAction.ImportActionCardClick -> handleImportActionCardClick()
|
||||
VaultAction.LifecycleResumed -> handleLifecycleResumed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLifecycleResumed() {
|
||||
val shouldShowPrompt = reviewPromptManager.shouldPromptForAppReview() &&
|
||||
featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt)
|
||||
if (shouldShowPrompt) {
|
||||
sendEvent(VaultEvent.PromptForAppReview)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1101,6 +1112,11 @@ sealed class VaultEvent {
|
|||
*/
|
||||
data object NavigateToImportLogins : VaultEvent()
|
||||
|
||||
/**
|
||||
* Indicates that we should prompt the user for app review.
|
||||
*/
|
||||
data object PromptForAppReview : VaultEvent()
|
||||
|
||||
/**
|
||||
* Show a toast with the given [message].
|
||||
*/
|
||||
|
@ -1275,6 +1291,11 @@ sealed class VaultAction {
|
|||
val password: String,
|
||||
) : VaultAction()
|
||||
|
||||
/**
|
||||
* The lifecycle of the VaultScreen has entered a resumed state.
|
||||
*/
|
||||
data object LifecycleResumed : VaultAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [VaultViewModel] itself might send.
|
||||
*/
|
||||
|
|
|
@ -1115,4 +1115,5 @@ Do you want to switch to this account?</string>
|
|||
<string name="check_out_the_passphrase_generator">"Check out the passphrase generator"</string>
|
||||
<string name="copied_to_clipboard">Copied to clipboard.</string>
|
||||
<string name="we_couldnt_verify_the_servers_certificate">We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string>
|
||||
<string name="review_flow_launched">Review flow launched!</string>
|
||||
</resources>
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package com.x8bit.bitwarden.ui.platform.manager.review
|
||||
|
||||
import android.app.Activity
|
||||
import android.widget.Toast
|
||||
import com.google.android.play.core.review.ReviewManagerFactory
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [AppReviewManager].
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
class AppReviewManagerImpl(
|
||||
private val activity: Activity,
|
||||
) : AppReviewManager {
|
||||
override fun promptForReview() {
|
||||
val manager = ReviewManagerFactory.create(activity)
|
||||
val request = manager.requestReviewFlow()
|
||||
request.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val reviewInfo = task.result
|
||||
manager.launchReviewFlow(activity, reviewInfo)
|
||||
} else {
|
||||
Timber.e(task.exception, "Failed to launch review flow.")
|
||||
}
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
Toast
|
||||
.makeText(
|
||||
activity,
|
||||
activity.getString(R.string.review_flow_launched),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -23,12 +23,14 @@ import com.x8bit.bitwarden.R
|
|||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
|
||||
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
|
||||
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
|
||||
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
|
||||
|
@ -50,7 +52,9 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
|||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -73,7 +77,9 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
private var onNavigateToSearchScreen = false
|
||||
private val exitManager = mockk<ExitManager>(relaxed = true)
|
||||
private val intentManager = mockk<IntentManager>(relaxed = true)
|
||||
|
||||
private val appReviewManager: AppReviewManager = mockk {
|
||||
every { promptForReview() } just runs
|
||||
}
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<VaultEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<VaultViewModel>(relaxed = true) {
|
||||
|
@ -99,6 +105,7 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
},
|
||||
exitManager = exitManager,
|
||||
intentManager = intentManager,
|
||||
appReviewManager = appReviewManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1274,6 +1281,18 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
.onNodeWithText("mockSshKey")
|
||||
.isNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LifecycleResumed action is sent when the screen is resumed`() {
|
||||
verify { viewModel.trySendAction(VaultAction.LifecycleResumed) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PromptForAppReview triggers app review manager`() {
|
||||
mutableEventFlow.tryEmit(VaultEvent.PromptForAppReview)
|
||||
dispatcher.advanceTimeByAndRunCurrent(4000L)
|
||||
verify(exactly = 1) { appReviewManager.promptForReview() }
|
||||
}
|
||||
}
|
||||
|
||||
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
|||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
|
@ -143,6 +144,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
getFeatureFlag(FlagKey.SshKeyCipherItems)
|
||||
} returns mutableSshKeyVaultItemsEnabledFlow.value
|
||||
}
|
||||
private val reviewPromptManager: ReviewPromptManager = mockk()
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct and should trigger a syncIfNecessary call`() {
|
||||
|
@ -1812,6 +1814,45 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when LifecycleResumed action is handled, PromptForAppReview is sent if flag is enabled and criteria is met`() =
|
||||
runTest {
|
||||
every { featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt) } returns true
|
||||
every { reviewPromptManager.shouldPromptForAppReview() } returns true
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultAction.LifecycleResumed)
|
||||
assertEquals(VaultEvent.PromptForAppReview, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when LifecycleResumed action is handled, PromptForAppReview is not sent if flag is disabled`() =
|
||||
runTest {
|
||||
every { featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt) } returns false
|
||||
every { reviewPromptManager.shouldPromptForAppReview() } returns true
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultAction.LifecycleResumed)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when LifecycleResumed action is handled, PromptForAppReview is not sent if criteria is not met`() =
|
||||
runTest {
|
||||
every { featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt) } returns true
|
||||
every { reviewPromptManager.shouldPromptForAppReview() } returns false
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultAction.LifecycleResumed)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): VaultViewModel =
|
||||
VaultViewModel(
|
||||
authRepository = authRepository,
|
||||
|
@ -1824,6 +1865,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
featureFlagManager = featureFlagManager,
|
||||
firstTimeActionManager = firstTimeActionManager,
|
||||
snackbarRelayManager = snackbarRelayManager,
|
||||
reviewPromptManager = reviewPromptManager,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ detekt = "1.23.7"
|
|||
firebaseBom = "33.7.0"
|
||||
glide = "1.0.0-beta01"
|
||||
googleServices = "4.4.2"
|
||||
googleReview = "2.0.2"
|
||||
hilt = "2.53.1"
|
||||
junit5 = "5.11.3"
|
||||
jvmTarget = "17"
|
||||
|
@ -96,6 +97,7 @@ google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlyti
|
|||
google-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
google-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||
google-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
google-play-review = { module = "com.google.android.play:review", version.ref = "googleReview"}
|
||||
junit-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" }
|
||||
junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" }
|
||||
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
|
||||
|
|
Loading…
Add table
Reference in a new issue