mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BITAU-97 Add AuthenticatorBridgeManager
(#3987)
This commit is contained in:
parent
757baf0290
commit
9e4119fe32
20 changed files with 965 additions and 0 deletions
Binary file not shown.
BIN
app/libs/bridge-0.1.0-SNAPSHOT-release.aar
Normal file
BIN
app/libs/bridge-0.1.0-SNAPSHOT-release.aar
Normal file
Binary file not shown.
|
@ -54,6 +54,21 @@ The following is a list of all third-party dependencies required by the SDK.
|
|||
> [!IMPORTANT]
|
||||
> The SDK does not come packaged with these dependencies, so consumers of the SDK must provide them.
|
||||
|
||||
- **AndroidX Appcompat**
|
||||
- https://developer.android.com/jetpack/androidx/releases/appcompat
|
||||
- Purpose: Allows access to new APIs on older API versions.
|
||||
- License: Apache 2.0
|
||||
|
||||
- **AndroidX Lifecycle**
|
||||
- https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
- Purpose: Lifecycle aware components and tooling.
|
||||
- License: Apache 2.0
|
||||
|
||||
- **kotlinx.coroutines**
|
||||
- https://github.com/Kotlin/kotlinx.coroutines
|
||||
- Purpose: Kotlin coroutines library for asynchronous and reactive code.
|
||||
- License: Apache 2.0
|
||||
|
||||
- **kotlinx.serialization**
|
||||
- https://github.com/Kotlin/kotlinx.serialization/
|
||||
- Purpose: JSON serialization library for Kotlin.
|
||||
|
|
|
@ -60,7 +60,10 @@ kotlin {
|
|||
|
||||
dependencies {
|
||||
// SDK dependencies:
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Test environment dependencies:
|
||||
testImplementation(libs.junit.junit5)
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package com.bitwarden.authenticatorbridge.factory
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
|
||||
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManagerImpl
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
|
||||
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
|
||||
|
||||
/**
|
||||
* Factory for supplying implementation instances of Authenticator Bridge SDK interfaces.
|
||||
*/
|
||||
class AuthenticatorBridgeFactory(
|
||||
context: Context,
|
||||
) {
|
||||
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
/**
|
||||
* Gets a new instance of [AuthenticatorBridgeManager].
|
||||
*
|
||||
* @param connectionType Specifies which build variant to connect to.
|
||||
* @param symmetricKeyStorageProvider Provides access to local storage of the symmetric
|
||||
* encryption key.
|
||||
*/
|
||||
fun getAuthenticatorBridgeManager(
|
||||
connectionType: AuthenticatorBridgeConnectionType,
|
||||
symmetricKeyStorageProvider: SymmetricKeyStorageProvider,
|
||||
): AuthenticatorBridgeManager = AuthenticatorBridgeManagerImpl(
|
||||
context = applicationContext,
|
||||
connectionType = connectionType,
|
||||
symmetricKeyStorageProvider = symmetricKeyStorageProvider,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.bitwarden.authenticatorbridge.manager
|
||||
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Provides an API to make it simpler for consuming applications to
|
||||
* query [IAuthenticatorBridgeService].
|
||||
*/
|
||||
interface AuthenticatorBridgeManager {
|
||||
|
||||
/**
|
||||
* State flow representing the current [AccountSyncState].
|
||||
*/
|
||||
val accountSyncStateFlow: StateFlow<AccountSyncState>
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
package com.bitwarden.authenticatorbridge.manager
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.os.IBinder
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
|
||||
import com.bitwarden.authenticatorbridge.manager.util.toPackageName
|
||||
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
|
||||
import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider
|
||||
import com.bitwarden.authenticatorbridge.provider.StubAuthenticatorBridgeCallbackProvider
|
||||
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
|
||||
import com.bitwarden.authenticatorbridge.util.decrypt
|
||||
import com.bitwarden.authenticatorbridge.util.toFingerprint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
private const val AUTHENTICATOR_BRIDGE_SERVICE_CLASS =
|
||||
"com.x8bit.bitwarden.data.platform.service.AuthenticatorBridgeService"
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthenticatorBridgeManager].
|
||||
*
|
||||
* @param context The Context that will be used to bind to AuthenticatorBridgeService.
|
||||
* @param connectionType Specifies which build variant to connect to.
|
||||
* @param symmetricKeyStorageProvider Provides access to local storage of the symmetric encryption
|
||||
* key.
|
||||
* @param callbackProvider Provides a way to construct a service callback that can be mocked in
|
||||
* tests.
|
||||
* @param processLifecycleOwner Lifecycle owner that is used to listen for start/stop
|
||||
* lifecycle events.
|
||||
*/
|
||||
internal class AuthenticatorBridgeManagerImpl(
|
||||
private val connectionType: AuthenticatorBridgeConnectionType,
|
||||
private val symmetricKeyStorageProvider: SymmetricKeyStorageProvider,
|
||||
callbackProvider: AuthenticatorBridgeCallbackProvider = StubAuthenticatorBridgeCallbackProvider(),
|
||||
context: Context,
|
||||
processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(),
|
||||
) : AuthenticatorBridgeManager {
|
||||
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
/**
|
||||
* Main AuthenticatorBridgeService access point.
|
||||
*/
|
||||
private var bridgeService: IAuthenticatorBridgeService? = null
|
||||
|
||||
/**
|
||||
* Internal state of [accountSyncStateFlow].
|
||||
*/
|
||||
private val mutableSharedAccountsStateFlow: MutableStateFlow<AccountSyncState> =
|
||||
MutableStateFlow(
|
||||
if (isBitwardenAppInstalled()) {
|
||||
AccountSyncState.Loading
|
||||
} else {
|
||||
AccountSyncState.AppNotInstalled
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Callback registered with AuthenticatorBridgeService.
|
||||
*/
|
||||
private val authenticatorBridgeCallback = callbackProvider.getCallback(::onAccountsSync)
|
||||
|
||||
/**
|
||||
* Service connection that listens for connected and disconnected service events.
|
||||
*/
|
||||
private val bridgeServiceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
onServiceConnected(service)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
onServiceDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
override val accountSyncStateFlow: StateFlow<AccountSyncState> =
|
||||
mutableSharedAccountsStateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
// Listen for lifecycle events
|
||||
processLifecycleOwner.lifecycle.addObserver(
|
||||
object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
bindService()
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
unbindService()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindService() {
|
||||
if (!isBitwardenAppInstalled()) {
|
||||
mutableSharedAccountsStateFlow.value = AccountSyncState.AppNotInstalled
|
||||
return
|
||||
}
|
||||
val intent = Intent().apply {
|
||||
component = ComponentName(
|
||||
connectionType.toPackageName(),
|
||||
AUTHENTICATOR_BRIDGE_SERVICE_CLASS,
|
||||
)
|
||||
}
|
||||
|
||||
val isBound = try {
|
||||
applicationContext.bindService(
|
||||
intent,
|
||||
bridgeServiceConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
unbindService()
|
||||
false
|
||||
}
|
||||
|
||||
if (!isBound) {
|
||||
mutableSharedAccountsStateFlow.value = AccountSyncState.Error
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAccountsSync(data: EncryptedSharedAccountData) {
|
||||
// Received account sync update. Decrypt with local symmetric key and update StateFlow:
|
||||
mutableSharedAccountsStateFlow.value = symmetricKeyStorageProvider.symmetricKey
|
||||
?.let { data.decrypt(it) }
|
||||
?.getOrNull()
|
||||
?.let { AccountSyncState.Success(it.accounts) }
|
||||
?: AccountSyncState.Error
|
||||
}
|
||||
|
||||
private fun onServiceConnected(binder: IBinder) {
|
||||
val service = IAuthenticatorBridgeService.Stub
|
||||
.asInterface(binder)
|
||||
.also { bridgeService = it }
|
||||
|
||||
// TODO: Add check for version mismatch between client and server SDKs: BITAU-72
|
||||
|
||||
// Ensure we are using the correct symmetric key:
|
||||
val localKeyFingerprint =
|
||||
symmetricKeyStorageProvider.symmetricKey?.toFingerprint()?.getOrNull()
|
||||
|
||||
// Query bridge service to see if we have a matching symmetric key:
|
||||
val haveCorrectKey = service
|
||||
.safeCall { checkSymmetricEncryptionKeyFingerprint(localKeyFingerprint) }
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { false },
|
||||
)
|
||||
|
||||
if (!haveCorrectKey) {
|
||||
// If we don't have the correct key, query for key:
|
||||
service
|
||||
.safeCall { symmetricEncryptionKeyData }
|
||||
.fold(
|
||||
onSuccess = {
|
||||
symmetricKeyStorageProvider.symmetricKey = it
|
||||
},
|
||||
onFailure = {
|
||||
mutableSharedAccountsStateFlow.value = AccountSyncState.Error
|
||||
unbindService()
|
||||
return
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
if (symmetricKeyStorageProvider.symmetricKey == null) {
|
||||
// This means bridgeService returned a null key, which means we can make
|
||||
// no valid operations. We should disconnect form the service and expose to the
|
||||
// calling application that authenticator sync is not enabled.
|
||||
mutableSharedAccountsStateFlow.value = AccountSyncState.SyncNotEnabled
|
||||
unbindService()
|
||||
return
|
||||
}
|
||||
|
||||
// Register callback:
|
||||
service.safeCall { registerBridgeServiceCallback(authenticatorBridgeCallback) }
|
||||
|
||||
// Sync data:
|
||||
service.safeCall { syncAccounts() }
|
||||
}
|
||||
|
||||
private fun onServiceDisconnected() {
|
||||
bridgeService = null
|
||||
}
|
||||
|
||||
private fun unbindService() {
|
||||
bridgeService?.safeCall { unregisterBridgeServiceCallback(authenticatorBridgeCallback) }
|
||||
bridgeService = null
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
applicationContext.unbindService(bridgeServiceConnection)
|
||||
} catch (_: Exception) {
|
||||
// We want to be super safe when unbinding to assure no crashes.
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBitwardenAppInstalled(): Boolean =
|
||||
// Check to see if correct Bitwarden app is installed:
|
||||
try {
|
||||
applicationContext.packageManager.getPackageInfo(connectionType.toPackageName(), 0)
|
||||
true
|
||||
} catch (e: NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for wrapping all calls to [IAuthenticatorBridgeService] around try catch.
|
||||
*
|
||||
* This is important because all calls to [IAuthenticatorBridgeService] can throw
|
||||
* DeadObjectExceptions as well as RemoteExceptions.
|
||||
*/
|
||||
private fun <T> IAuthenticatorBridgeService.safeCall(
|
||||
action: IAuthenticatorBridgeService.() -> T,
|
||||
): Result<T> =
|
||||
runCatching {
|
||||
this.let { action.invoke(it) }
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.bitwarden.authenticatorbridge.manager.model
|
||||
|
||||
import com.bitwarden.authenticatorbridge.model.SharedAccountData
|
||||
|
||||
/**
|
||||
* Models various states of account syncing.
|
||||
*/
|
||||
sealed class AccountSyncState {
|
||||
|
||||
/**
|
||||
* The Bitwarden app is not installed and therefore accounts cannot be synced.
|
||||
*/
|
||||
data object AppNotInstalled : AccountSyncState()
|
||||
|
||||
/**
|
||||
* Something went wrong syncing accounts.
|
||||
*/
|
||||
data object Error : AccountSyncState()
|
||||
|
||||
/**
|
||||
* The user needs to enable authenticator syncing from the bitwarden app.
|
||||
*/
|
||||
data object SyncNotEnabled : AccountSyncState()
|
||||
|
||||
/**
|
||||
* Accounts are being synced.
|
||||
*/
|
||||
data object Loading : AccountSyncState()
|
||||
|
||||
/**
|
||||
* Accounts successfully synced.
|
||||
*/
|
||||
data class Success(val accounts: List<SharedAccountData.Account>) : AccountSyncState()
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.bitwarden.authenticatorbridge.manager.model
|
||||
|
||||
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
|
||||
|
||||
/**
|
||||
* Models different connection types for [AuthenticatorBridgeManager].
|
||||
*/
|
||||
enum class AuthenticatorBridgeConnectionType {
|
||||
|
||||
/**
|
||||
* Connect to release build variant.
|
||||
*/
|
||||
RELEASE,
|
||||
|
||||
/**
|
||||
* Connect to dev build variant.
|
||||
*/
|
||||
DEV,
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.bitwarden.authenticatorbridge.manager.util
|
||||
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
|
||||
|
||||
/**
|
||||
* Convert a [AuthenticatorBridgeConnectionType] to raw package name for connection.
|
||||
*/
|
||||
internal fun AuthenticatorBridgeConnectionType.toPackageName() =
|
||||
when (this) {
|
||||
AuthenticatorBridgeConnectionType.RELEASE -> "com.x8bit.bitwarden"
|
||||
AuthenticatorBridgeConnectionType.DEV -> "com.x8bit.bitwarden.dev"
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package com.bitwarden.authenticatorbridge.provider
|
||||
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
|
||||
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
|
||||
|
||||
/**
|
||||
* Provides an implementation of [IAuthenticatorBridgeServiceCallback]. This is useful
|
||||
* for writing unit tests that don't touch Binder logic.
|
||||
*/
|
||||
interface AuthenticatorBridgeCallbackProvider {
|
||||
|
||||
/**
|
||||
* Get a [IAuthenticatorBridgeServiceCallback] that will call delegate [onAccountsSync] call
|
||||
* to the given lambda.
|
||||
*
|
||||
* @param onAccountsSync Lambda that will be invoked when [onAccountsSync] calls back.
|
||||
*/
|
||||
fun getCallback(
|
||||
onAccountsSync: (EncryptedSharedAccountData) -> Unit,
|
||||
): IAuthenticatorBridgeServiceCallback
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.bitwarden.authenticatorbridge.provider
|
||||
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
|
||||
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthenticatorBridgeCallbackProvider] that provides a live
|
||||
* [IAuthenticatorBridgeServiceCallback.Stub] implementation.
|
||||
*/
|
||||
class StubAuthenticatorBridgeCallbackProvider : AuthenticatorBridgeCallbackProvider {
|
||||
|
||||
override fun getCallback(
|
||||
onAccountsSync: (EncryptedSharedAccountData) -> Unit,
|
||||
): IAuthenticatorBridgeServiceCallback = object : IAuthenticatorBridgeServiceCallback.Stub() {
|
||||
|
||||
override fun onAccountsSync(data: EncryptedSharedAccountData) = onAccountsSync.invoke(data)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package com.bitwarden.authenticatorbridge.provider
|
||||
|
||||
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
|
||||
|
||||
/**
|
||||
* Provides a way for calling Applications to implement symmetric key storage.
|
||||
*/
|
||||
interface SymmetricKeyStorageProvider {
|
||||
|
||||
/**
|
||||
* Stored symmetric encryption key.
|
||||
*/
|
||||
var symmetricKey: SymmetricEncryptionKeyData?
|
||||
}
|
|
@ -0,0 +1,440 @@
|
|||
package com.bitwarden.authenticatorbridge.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeService
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AccountSyncState
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
|
||||
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
|
||||
import com.bitwarden.authenticatorbridge.model.SharedAccountData
|
||||
import com.bitwarden.authenticatorbridge.util.FakeLifecycleOwner
|
||||
import com.bitwarden.authenticatorbridge.util.FakeSymmetricKeyStorageProvider
|
||||
import com.bitwarden.authenticatorbridge.util.TestAuthenticatorBridgeCallbackProvider
|
||||
import com.bitwarden.authenticatorbridge.util.decrypt
|
||||
import com.bitwarden.authenticatorbridge.util.generateSecretKey
|
||||
import com.bitwarden.authenticatorbridge.util.toFingerprint
|
||||
import com.bitwarden.authenticatorbridge.util.toSymmetricEncryptionKeyData
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkConstructor
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkConstructor
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AuthenticatorBridgeManagerTest {
|
||||
|
||||
private val context = mockk<Context> {
|
||||
every { applicationContext } returns this
|
||||
every {
|
||||
packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0)
|
||||
} returns mockk()
|
||||
}
|
||||
private val mockBridgeService: IAuthenticatorBridgeService = mockk()
|
||||
private val fakeLifecycleOwner = FakeLifecycleOwner()
|
||||
private val fakeSymmetricKeyStorageProvider = FakeSymmetricKeyStorageProvider()
|
||||
private val testAuthenticatorBridgeCallbackProvider = TestAuthenticatorBridgeCallbackProvider()
|
||||
|
||||
private val manager: AuthenticatorBridgeManagerImpl = AuthenticatorBridgeManagerImpl(
|
||||
context = context,
|
||||
connectionType = AuthenticatorBridgeConnectionType.DEV,
|
||||
symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider,
|
||||
callbackProvider = testAuthenticatorBridgeCallbackProvider,
|
||||
processLifecycleOwner = fakeLifecycleOwner,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkConstructor(Intent::class)
|
||||
mockkStatic(IAuthenticatorBridgeService.Stub::class)
|
||||
mockkStatic(EncryptedSharedAccountData::decrypt)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
unmockkConstructor(Intent::class)
|
||||
unmockkStatic(IAuthenticatorBridgeService.Stub::class)
|
||||
unmockkStatic(EncryptedSharedAccountData::decrypt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial AccountSyncState should be Loading when Bitwarden app is present`() {
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial AccountSyncState should be AppNotInstalled when Bitwarden app is not present`() {
|
||||
every {
|
||||
context.packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0)
|
||||
} throws NameNotFoundException()
|
||||
val manager = AuthenticatorBridgeManagerImpl(
|
||||
context = context,
|
||||
connectionType = AuthenticatorBridgeConnectionType.DEV,
|
||||
symmetricKeyStorageProvider = fakeSymmetricKeyStorageProvider,
|
||||
callbackProvider = testAuthenticatorBridgeCallbackProvider,
|
||||
processLifecycleOwner = fakeLifecycleOwner,
|
||||
)
|
||||
assertEquals(AccountSyncState.AppNotInstalled, manager.accountSyncStateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onStart when bindService fails should set state to error`() {
|
||||
val mockIntent: Intent = mockk()
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
|
||||
every { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) } returns false
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
|
||||
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onStart when Bitwarden app is not present should set state to AppNotInstalled`() {
|
||||
val mockIntent: Intent = mockk()
|
||||
every {
|
||||
context.packageManager.getPackageInfo("com.x8bit.bitwarden.dev", 0)
|
||||
} throws NameNotFoundException()
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
|
||||
every {
|
||||
context.bindService(any(), any(), Context.BIND_AUTO_CREATE)
|
||||
} throws SecurityException()
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
|
||||
assertEquals(AccountSyncState.AppNotInstalled, manager.accountSyncStateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onStart when bindService throws security exception should set state to error`() {
|
||||
val mockIntent: Intent = mockk()
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
|
||||
every {
|
||||
context.bindService(any(), any(), Context.BIND_AUTO_CREATE)
|
||||
} throws SecurityException()
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
|
||||
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onStart when Bitwarden app is present and bindService succeeds should set state to Loading before service calls back`() {
|
||||
val mockIntent: Intent = mockk()
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
|
||||
every { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) } returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `onServiceConnected when symmetric key is not present and service returns null symmetric key state should be SyncNotEnabled and should unbind service`() {
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val mockIntent: Intent = mockk()
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = null
|
||||
every { mockBridgeService.symmetricEncryptionKeyData } returns null
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
|
||||
assertEquals(AccountSyncState.SyncNotEnabled, manager.accountSyncStateFlow.value)
|
||||
verify { mockBridgeService.symmetricEncryptionKeyData }
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { context.unbindService(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `onServiceConnected when symmetric key does not match should set symmetric key from service`() {
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = null
|
||||
every { mockBridgeService.symmetricEncryptionKeyData } returns SYMMETRIC_KEY
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val mockIntent: Intent = mockk()
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
|
||||
verify { mockBridgeService.symmetricEncryptionKeyData }
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `onServiceConnected when symmetric key does match should not query for symmetric key`() {
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
|
||||
every {
|
||||
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
|
||||
SYMMETRIC_KEY.toFingerprint().getOrNull()
|
||||
)
|
||||
} returns true
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val mockIntent: Intent = mockk()
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
|
||||
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
|
||||
verify { mockBridgeService.syncAccounts() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `onServiceConnected when symmetric key does not match and query for key fails state should be error`() {
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
|
||||
every {
|
||||
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
|
||||
SYMMETRIC_KEY.toFingerprint().getOrNull()
|
||||
)
|
||||
} returns false
|
||||
every {
|
||||
mockBridgeService.symmetricEncryptionKeyData
|
||||
} throws RuntimeException()
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val mockIntent: Intent = mockk()
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
|
||||
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
|
||||
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { context.unbindService(any()) }
|
||||
verify { mockBridgeService.symmetricEncryptionKeyData }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `onAccountsSync should set AccountSyncState to decrypted response`() {
|
||||
val expectedAccounts = listOf<SharedAccountData.Account>(
|
||||
mockk()
|
||||
)
|
||||
val encryptedAccounts: EncryptedSharedAccountData = mockk()
|
||||
val decryptedAccounts: SharedAccountData = mockk {
|
||||
every { accounts } returns expectedAccounts
|
||||
}
|
||||
mockkStatic(EncryptedSharedAccountData::decrypt)
|
||||
every { encryptedAccounts.decrypt(SYMMETRIC_KEY) } returns Result.success(decryptedAccounts)
|
||||
every {
|
||||
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
|
||||
SYMMETRIC_KEY.toFingerprint().getOrNull()
|
||||
)
|
||||
} returns true
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val callback = slot<IAuthenticatorBridgeServiceCallback>()
|
||||
val mockIntent: Intent = mockk()
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every { mockBridgeService.registerBridgeServiceCallback(capture(callback)) } just runs
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
|
||||
callback.captured.onAccountsSync(encryptedAccounts)
|
||||
assertEquals(AccountSyncState.Success(expectedAccounts), manager.accountSyncStateFlow.value)
|
||||
|
||||
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
|
||||
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
|
||||
verify { mockBridgeService.syncAccounts() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onAccountsSync when symmetric key is missing should not set state to Success`() {
|
||||
val encryptedAccounts: EncryptedSharedAccountData = mockk()
|
||||
every {
|
||||
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
|
||||
SYMMETRIC_KEY.toFingerprint().getOrNull()
|
||||
)
|
||||
} returns true
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val callback = slot<IAuthenticatorBridgeServiceCallback>()
|
||||
val mockIntent: Intent = mockk()
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every { mockBridgeService.registerBridgeServiceCallback(capture(callback)) } just runs
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = null
|
||||
callback.captured.onAccountsSync(encryptedAccounts)
|
||||
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
|
||||
|
||||
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
|
||||
verify { mockBridgeService.syncAccounts() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `onAccountsSync when decryption fails state should be error`() {
|
||||
val encryptedAccounts: EncryptedSharedAccountData = mockk()
|
||||
mockkStatic(EncryptedSharedAccountData::decrypt)
|
||||
every { encryptedAccounts.decrypt(SYMMETRIC_KEY) } returns Result.failure(RuntimeException())
|
||||
every {
|
||||
mockBridgeService.checkSymmetricEncryptionKeyFingerprint(
|
||||
SYMMETRIC_KEY.toFingerprint().getOrNull()
|
||||
)
|
||||
} returns true
|
||||
fakeSymmetricKeyStorageProvider.symmetricKey = SYMMETRIC_KEY
|
||||
val serviceConnection = slot<ServiceConnection>()
|
||||
val callback = slot<IAuthenticatorBridgeServiceCallback>()
|
||||
val mockIntent: Intent = mockk()
|
||||
every { IAuthenticatorBridgeService.Stub.asInterface(any()) } returns mockBridgeService
|
||||
every { mockBridgeService.registerBridgeServiceCallback(capture(callback)) } just runs
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
every { context.unbindService(any()) } just runs
|
||||
every {
|
||||
context.bindService(
|
||||
any(),
|
||||
capture(serviceConnection),
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
} returns true
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
serviceConnection.captured.onServiceConnected(mockk(), mockk())
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
|
||||
callback.captured.onAccountsSync(encryptedAccounts)
|
||||
assertEquals(AccountSyncState.Error, manager.accountSyncStateFlow.value)
|
||||
|
||||
assertEquals(SYMMETRIC_KEY, fakeSymmetricKeyStorageProvider.symmetricKey)
|
||||
verify(exactly = 0) { mockBridgeService.symmetricEncryptionKeyData }
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { mockBridgeService.registerBridgeServiceCallback(any()) }
|
||||
verify { mockBridgeService.syncAccounts() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onStop when service has been started should unbind service`() {
|
||||
val mockIntent: Intent = mockk()
|
||||
every {
|
||||
anyConstructed<Intent>().setComponent(any())
|
||||
} returns mockIntent
|
||||
|
||||
every { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) } returns true
|
||||
every { context.unbindService(any()) } just runs
|
||||
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStart()
|
||||
fakeLifecycleOwner.lifecycle.dispatchOnStop()
|
||||
|
||||
assertEquals(AccountSyncState.Loading, manager.accountSyncStateFlow.value)
|
||||
verify { context.bindService(any(), any(), Context.BIND_AUTO_CREATE) }
|
||||
verify { context.unbindService(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
private val SYMMETRIC_KEY = generateSecretKey()
|
||||
.getOrThrow()
|
||||
.encoded
|
||||
.toSymmetricEncryptionKeyData()
|
|
@ -0,0 +1,24 @@
|
|||
package com.bitwarden.authenticatorbridge.manager.util
|
||||
|
||||
import com.bitwarden.authenticatorbridge.manager.model.AuthenticatorBridgeConnectionType
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AuthenticatorBridgeConnectionTypeExtensionsTest {
|
||||
|
||||
@Test
|
||||
fun `toPackageName RELEASE should map to correct release package`() {
|
||||
assertEquals(
|
||||
"com.x8bit.bitwarden",
|
||||
AuthenticatorBridgeConnectionType.RELEASE.toPackageName()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toPackageName DEV should map to correct dev package`() {
|
||||
assertEquals(
|
||||
"com.x8bit.bitwarden.dev",
|
||||
AuthenticatorBridgeConnectionType.DEV.toPackageName()
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.bitwarden.authenticatorbridge.util
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
/**
|
||||
* A fake implementation of [LifecycleOwner] and [Lifecycle] for testing purposes.
|
||||
*/
|
||||
class FakeLifecycle(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
) : Lifecycle() {
|
||||
private val observers = mutableSetOf<DefaultLifecycleObserver>()
|
||||
|
||||
override var currentState: State = State.INITIALIZED
|
||||
|
||||
override fun addObserver(observer: LifecycleObserver) {
|
||||
observers += (observer as DefaultLifecycleObserver)
|
||||
}
|
||||
|
||||
override fun removeObserver(observer: LifecycleObserver) {
|
||||
observers -= (observer as DefaultLifecycleObserver)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers [DefaultLifecycleObserver.onStart] calls for each registered observer.
|
||||
*/
|
||||
fun dispatchOnStart() {
|
||||
currentState = State.STARTED
|
||||
observers.forEach { observer ->
|
||||
observer.onStart(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers [DefaultLifecycleObserver.onStop] calls for each registered observer.
|
||||
*/
|
||||
fun dispatchOnStop() {
|
||||
currentState = State.CREATED
|
||||
observers.forEach { observer ->
|
||||
observer.onStop(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.bitwarden.authenticatorbridge.util
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
/**
|
||||
* A fake implementation of [LifecycleOwner] for testing purposes.
|
||||
*/
|
||||
class FakeLifecycleOwner : LifecycleOwner {
|
||||
override val lifecycle: FakeLifecycle = FakeLifecycle(lifecycleOwner = this)
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.bitwarden.authenticatorbridge.util
|
||||
|
||||
import com.bitwarden.authenticatorbridge.model.SymmetricEncryptionKeyData
|
||||
import com.bitwarden.authenticatorbridge.provider.SymmetricKeyStorageProvider
|
||||
|
||||
/**
|
||||
* A fake implementation of [SymmetricKeyStorageProvider] for testing purposes.
|
||||
*/
|
||||
class FakeSymmetricKeyStorageProvider : SymmetricKeyStorageProvider {
|
||||
override var symmetricKey: SymmetricEncryptionKeyData? = null
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.bitwarden.authenticatorbridge.util
|
||||
|
||||
import com.bitwarden.authenticatorbridge.IAuthenticatorBridgeServiceCallback
|
||||
import com.bitwarden.authenticatorbridge.model.EncryptedSharedAccountData
|
||||
import com.bitwarden.authenticatorbridge.provider.AuthenticatorBridgeCallbackProvider
|
||||
|
||||
/**
|
||||
* Test implementation of [AuthenticatorBridgeCallbackProvider] that provides a testable
|
||||
* [IAuthenticatorBridgeServiceCallback.Default] implementation.
|
||||
*/
|
||||
class TestAuthenticatorBridgeCallbackProvider : AuthenticatorBridgeCallbackProvider {
|
||||
|
||||
override fun getCallback(
|
||||
onAccountsSync: (EncryptedSharedAccountData) -> Unit,
|
||||
): IAuthenticatorBridgeServiceCallback = object : IAuthenticatorBridgeServiceCallback.Default() {
|
||||
|
||||
override fun onAccountsSync(data: EncryptedSharedAccountData) = onAccountsSync.invoke(data)
|
||||
}
|
||||
}
|
|
@ -97,6 +97,7 @@ junit-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "juni
|
|||
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" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
mockk-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
|
|
Loading…
Reference in a new issue