Implemented biometric authentication on Android
This commit is contained in:
parent
4697119c58
commit
9412f6b7f0
|
@ -91,6 +91,7 @@ kotlin {
|
|||
androidMain.dependencies {
|
||||
implementation(compose.preview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.biometric)
|
||||
|
||||
implementation(libs.sqldelight.android.driver)
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
|
@ -1,21 +1,24 @@
|
|||
package net.codinux.banking.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import app.cash.sqldelight.async.coroutines.synchronous
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
import net.codinux.banking.dataaccess.BankmeisterDb
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.service.AuthenticationService
|
||||
import net.codinux.banking.ui.service.BiometricAuthenticationService
|
||||
import net.codinux.banking.ui.service.ImageService
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
ImageService.context = this.applicationContext
|
||||
AuthenticationService.biometricAuthenticationService = BiometricAuthenticationService(this)
|
||||
|
||||
DI.setRepository(AndroidSqliteDriver(BankmeisterDb.Schema.synchronous(), this, "Bankmeister.db"))
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
|
||||
actual object AuthenticationService {
|
||||
|
||||
internal var biometricAuthenticationService: BiometricAuthenticationService? = null
|
||||
|
||||
|
||||
actual fun hashPassword(password: String): String =
|
||||
BCrypt.withDefaults().hashToString(12, password.toCharArray())
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
|
||||
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
|
||||
|
||||
|
||||
actual val supportsBiometricAuthentication: Boolean
|
||||
get() = biometricAuthenticationService?.supportsBiometricAuthentication ?: false
|
||||
|
||||
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
if (biometricAuthenticationService != null) {
|
||||
biometricAuthenticationService!!.authenticate(null, authenticationResult)
|
||||
} else {
|
||||
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import android.os.Build
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import net.codinux.banking.ui.R
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
import javax.crypto.Cipher
|
||||
|
||||
class BiometricAuthenticationService(
|
||||
private val activity: FragmentActivity
|
||||
) {
|
||||
|
||||
private val biometricManager: BiometricManager = BiometricManager.from(activity)
|
||||
|
||||
private val allowedAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) BIOMETRIC_STRONG
|
||||
else BIOMETRIC_STRONG or BIOMETRIC_WEAK
|
||||
|
||||
|
||||
val supportsBiometricAuthentication: Boolean by lazy {
|
||||
biometricManager.canAuthenticate(allowedAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
|
||||
fun authenticate(cipher: Cipher?, authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
val executor = ContextCompat.getMainExecutor(this.activity)
|
||||
|
||||
val biometricPrompt = BiometricPrompt(activity, executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errorString)
|
||||
authenticationResult(AuthenticationResult(false, errorString.toString()))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
|
||||
authenticationResult(AuthenticationResult(true))
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
authenticationResult(AuthenticationResult(false))
|
||||
}
|
||||
})
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(this.activity.getString(R.string.activity_login_authenticate_with_biometrics_prompt))
|
||||
//.setSubtitle() // TODO: add subtitle?
|
||||
//.setNegativeButtonText(this.activity.getString(android.R.string.cancel)) // is not allowed when device credentials are allowed
|
||||
.setAllowedAuthenticators(allowedAuthenticators)
|
||||
.build()
|
||||
|
||||
if (cipher == null) {
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
} else {
|
||||
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
<resources>
|
||||
<string name="app_name">Bankmeister</string>
|
||||
|
||||
<string name="activity_login_authenticate_with_biometrics_prompt">Authentifizieren Sich sich um die App zu entsperren</string>
|
||||
</resources>
|
|
@ -0,0 +1,23 @@
|
|||
package net.codinux.banking.ui.composables.authentification
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Fingerprint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
import net.codinux.banking.ui.service.AuthenticationService
|
||||
|
||||
@Composable
|
||||
fun BiometricAuthenticationButton(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.Center) {
|
||||
Button({ AuthenticationService.authenticateWithBiometrics(authenticationResult) }, enabled = AuthenticationService.supportsBiometricAuthentication) {
|
||||
Icon(Icons.Outlined.Fingerprint, "Sich mittels Biometrie authentifizieren", Modifier.size(84.dp))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package net.codinux.banking.ui.model
|
||||
|
||||
class AuthenticationResult(
|
||||
val successful: Boolean,
|
||||
val error: String? = null
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
return if (successful) {
|
||||
"Successful"
|
||||
}
|
||||
else {
|
||||
"Error occurred: $error"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -16,10 +16,12 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import bankmeister.composeapp.generated.resources.*
|
||||
import bankmeister.composeapp.generated.resources.Res
|
||||
import net.codinux.banking.ui.composables.authentification.BiometricAuthenticationButton
|
||||
import net.codinux.banking.ui.forms.PasswordTextField
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.service.PasswordService
|
||||
import net.codinux.banking.ui.service.AuthenticationService
|
||||
import org.jetbrains.compose.resources.imageResource
|
||||
|
||||
@Composable
|
||||
|
@ -30,9 +32,21 @@ fun LoginScreen(appSettings: AppSettings, onLoginSuccess: () -> Unit) {
|
|||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
fun successfullyLoggedIn() {
|
||||
onLoginSuccess()
|
||||
}
|
||||
|
||||
fun checkPassword() {
|
||||
if (appSettings.hashedPassword != null && PasswordService.checkPassword(password, appSettings.hashedPassword!!)) {
|
||||
onLoginSuccess()
|
||||
if (appSettings.hashedPassword != null && AuthenticationService.checkPassword(password, appSettings.hashedPassword!!)) {
|
||||
successfullyLoggedIn()
|
||||
} else {
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
fun checkBiometricLoginResult(result: AuthenticationResult) {
|
||||
if (result.successful) {
|
||||
successfullyLoggedIn()
|
||||
} else {
|
||||
showError = true
|
||||
}
|
||||
|
@ -74,6 +88,26 @@ fun LoginScreen(appSettings: AppSettings, onLoginSuccess: () -> Unit) {
|
|||
Text("Login", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
|
||||
if (showError) {
|
||||
Text("Biometrische Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", color = MaterialTheme.colors.error, fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
BiometricAuthenticationButton {
|
||||
checkBiometricLoginResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(appSettings.authenticationMethod) {
|
||||
if (appSettings.authenticationMethod == AppAuthenticationMethod.Biometric) {
|
||||
AuthenticationService.authenticateWithBiometrics { result ->
|
||||
checkBiometricLoginResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
package net.codinux.banking.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -13,6 +11,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import net.codinux.banking.ui.composables.authentification.BiometricAuthenticationButton
|
||||
import net.codinux.banking.ui.config.Colors
|
||||
import net.codinux.banking.ui.config.DI
|
||||
import net.codinux.banking.ui.config.Internationalization
|
||||
|
@ -21,7 +20,7 @@ import net.codinux.banking.ui.forms.PasswordTextField
|
|||
import net.codinux.banking.ui.forms.SegmentedControl
|
||||
import net.codinux.banking.ui.model.settings.AppAuthenticationMethod
|
||||
import net.codinux.banking.ui.model.settings.AppSettings
|
||||
import net.codinux.banking.ui.service.PasswordService
|
||||
import net.codinux.banking.ui.service.AuthenticationService
|
||||
|
||||
|
||||
private val buttonHeight = 50.dp
|
||||
|
@ -30,7 +29,7 @@ private val buttonHeight = 50.dp
|
|||
fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
|
||||
val currentAuthenticationMethod = appSettings.authenticationMethod
|
||||
|
||||
val isBiometricAuthenticationSupported = false
|
||||
val isBiometricAuthenticationSupported = AuthenticationService.supportsBiometricAuthentication
|
||||
|
||||
val supportedAuthenticationMethods = buildList {
|
||||
add(AppAuthenticationMethod.Password)
|
||||
|
@ -47,35 +46,30 @@ fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
|
|||
|
||||
var confirmedNewPassword by remember { mutableStateOf("") }
|
||||
|
||||
var hasAuthenticatedWithBiometric by remember { mutableStateOf(false) }
|
||||
|
||||
val isRequiredDataEntered by remember(newPassword, confirmedNewPassword) {
|
||||
derivedStateOf { newPassword.isNotBlank() && newPassword == confirmedNewPassword }
|
||||
derivedStateOf {
|
||||
(selectedAuthenticationMethod == AppAuthenticationMethod.Password && newPassword.isNotBlank() && newPassword == confirmedNewPassword)
|
||||
|| (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric && hasAuthenticatedWithBiometric)
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveAppSettings(appSettings: AppSettings) {
|
||||
fun saveNewAppProtection() {
|
||||
coroutineScope.launch {
|
||||
appSettings.authenticationMethod = selectedAuthenticationMethod
|
||||
appSettings.hashedPassword = if (selectedAuthenticationMethod == AppAuthenticationMethod.Password) AuthenticationService.hashPassword(newPassword)
|
||||
else null
|
||||
|
||||
DI.bankingService.saveAppSettings(appSettings)
|
||||
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppPasswordProtection() {
|
||||
appSettings.authenticationMethod = AppAuthenticationMethod.Password
|
||||
appSettings.hashedPassword = PasswordService.hashPassword(newPassword)
|
||||
|
||||
saveAppSettings(appSettings)
|
||||
}
|
||||
|
||||
fun removeAppProtection() {
|
||||
appSettings.authenticationMethod = AppAuthenticationMethod.None
|
||||
appSettings.hashedPassword = null
|
||||
|
||||
saveAppSettings(appSettings)
|
||||
}
|
||||
|
||||
|
||||
FullscreenViewBase("Appzugang schützen", showButtonBar = false, onClosed = onClosed) {
|
||||
Column(Modifier.fillMaxSize().padding(8.dp)) {
|
||||
|
@ -103,18 +97,24 @@ fun ProtectAppSettingsDialog(appSettings: AppSettings, onClosed: () -> Unit) {
|
|||
PasswordTextField(confirmedNewPassword, "Password bestätigen", Modifier.padding(top = 16.dp), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)) { confirmedNewPassword = it }
|
||||
}
|
||||
|
||||
if (selectedAuthenticationMethod == AppAuthenticationMethod.Biometric) {
|
||||
BiometricAuthenticationButton { result ->
|
||||
hasAuthenticatedWithBiometric = result.successful
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
if (selectedAuthenticationMethod == AppAuthenticationMethod.None) {
|
||||
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = currentAuthenticationMethod != AppAuthenticationMethod.None,
|
||||
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { removeAppProtection() }) {
|
||||
colors = ButtonDefaults.buttonColors(Colors.DestructiveColor), onClick = { saveNewAppProtection() }) {
|
||||
Text("Appzugangsschutz entfernen", color = Color.White)
|
||||
}
|
||||
} else {
|
||||
Button(modifier = Modifier.fillMaxWidth().height(buttonHeight), enabled = isRequiredDataEntered,
|
||||
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { setAppPasswordProtection() }) {
|
||||
colors = ButtonDefaults.buttonColors(Colors.Accent), onClick = { saveNewAppProtection() }) {
|
||||
Text("Setzen", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
|
||||
expect object AuthenticationService {
|
||||
|
||||
fun hashPassword(password: String): String
|
||||
|
||||
fun checkPassword(password: String, hashedPassword: String): Boolean
|
||||
|
||||
|
||||
val supportsBiometricAuthentication: Boolean
|
||||
|
||||
fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit)
|
||||
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
expect object PasswordService {
|
||||
|
||||
fun hashPassword(password: String): String
|
||||
|
||||
fun checkPassword(password: String, hashedPassword: String): Boolean
|
||||
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
|
||||
actual object PasswordService {
|
||||
actual object AuthenticationService {
|
||||
|
||||
actual fun hashPassword(password: String): String =
|
||||
BCrypt.withDefaults().hashToString(12, password.toCharArray())
|
||||
|
@ -10,4 +11,11 @@ actual object PasswordService {
|
|||
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
|
||||
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
|
||||
|
||||
|
||||
actual val supportsBiometricAuthentication = false
|
||||
|
||||
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
|
||||
}
|
||||
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
|
||||
actual object PasswordService {
|
||||
|
||||
actual fun hashPassword(password: String): String =
|
||||
BCrypt.withDefaults().hashToString(12, password.toCharArray())
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean =
|
||||
BCrypt.verifyer().verify(password.toCharArray(), hashedPassword).verified
|
||||
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
actual object PasswordService {
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
|
||||
actual object AuthenticationService {
|
||||
|
||||
// for iOS see e.g.
|
||||
// https://medium.com/@mohamed.ma872/strengthening-mobile-app-security-pbkdf2-bcrypt-and-scrypt-for-android-and-ios-8089b0edbf76
|
||||
|
@ -14,4 +16,11 @@ actual object PasswordService {
|
|||
return password == hashedPassword // TODO
|
||||
}
|
||||
|
||||
|
||||
actual val supportsBiometricAuthentication = false // TODO
|
||||
|
||||
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
authenticationResult(AuthenticationResult(false, "Biometrics is not implemented yet"))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
import net.codinux.banking.ui.model.AuthenticationResult
|
||||
|
||||
actual object AuthenticationService {
|
||||
|
||||
actual fun hashPassword(password: String): String {
|
||||
return password // TODO
|
||||
}
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
|
||||
return password == hashedPassword // TODO
|
||||
}
|
||||
|
||||
|
||||
actual val supportsBiometricAuthentication = false
|
||||
|
||||
actual fun authenticateWithBiometrics(authenticationResult: (AuthenticationResult) -> Unit) {
|
||||
authenticationResult(AuthenticationResult(false, "Biometrics is not supported"))
|
||||
}
|
||||
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
package net.codinux.banking.ui.service
|
||||
|
||||
actual object PasswordService {
|
||||
|
||||
actual fun hashPassword(password: String): String {
|
||||
return password // TODO
|
||||
}
|
||||
|
||||
actual fun checkPassword(password: String, hashedPassword: String): Boolean {
|
||||
return password == hashedPassword // TODO
|
||||
}
|
||||
|
||||
}
|
|
@ -25,6 +25,7 @@ androidx-core-ktx = "1.13.1"
|
|||
androidx-espresso-core = "3.6.1"
|
||||
androidx-lifecycle = "2.8.0"
|
||||
androidx-material = "1.12.0"
|
||||
androidx-biometric = "1.1.0"
|
||||
androidx-test-junit = "1.2.1"
|
||||
compose-plugin = "1.6.11"
|
||||
|
||||
|
@ -59,6 +60,7 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
|
|||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
|
||||
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidx-biometric" }
|
||||
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
|
|
Loading…
Reference in New Issue