After a successful registration, store the user's credentials with credential manager

This commit is contained in:
Z. Charles Dziura 2024-12-28 20:00:12 -05:00
parent 3a093b60b4
commit 5a437a9813
22 changed files with 216 additions and 220 deletions

View file

@ -85,6 +85,7 @@ kapt {
dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.biometric)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.datastore)
implementation(libs.androidx.lifecycle.runtime.ktx)

View file

@ -18,4 +18,8 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
-if class androidx.credentials.CredentialManager
-keep class androidx.credentials.playservices.** {
*;
}

View file

@ -14,6 +14,11 @@
android:supportsRtl="true"
android:theme="@style/Theme.DebtPirate"
android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name=".app.host.MainActivity"
android:exported="true"
@ -22,7 +27,7 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

View file

@ -27,6 +27,8 @@ import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.Conf
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.confirm.ConfirmationScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login.LoginScreen
import ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register.RegistrationScreen
import ing.bikeshedengineer.debtpirate.app.screen.home.presentation.overview.OverviewScreen
import ing.bikeshedengineer.debtpirate.domain.usecase.GetStoredTokensUseCase
import ing.bikeshedengineer.debtpirate.navigation.Destination
import ing.bikeshedengineer.debtpirate.navigation.NavigationAction
import ing.bikeshedengineer.debtpirate.navigation.Navigator
@ -42,6 +44,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var navigator: Navigator
@Inject
lateinit var getStoredTokens: GetStoredTokensUseCase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -104,6 +109,14 @@ class MainActivity : ComponentActivity() {
ConfirmationScreen(ConfirmationData.UserConfirmationData(userId, verificationToken))
}
}
navigation<Destination.HomeGraph>(
startDestination = Destination.HomeOverview
) {
composable<Destination.HomeOverview> {
OverviewScreen()
}
}
}
}
}

View file

@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -32,8 +31,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -44,10 +41,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.credentials.CredentialManager
import androidx.hilt.navigation.compose.hiltViewModel
import ing.bikeshedengineer.debtpirate.domain.model.AccountManagerResult
import ing.bikeshedengineer.debtpirate.domain.repository.AccountManager
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
@ -55,23 +50,7 @@ fun LoginScreen(
viewModel: LoginScreenViewModel = hiltViewModel<LoginScreenViewModel>()
) {
val context = LocalContext.current as ComponentActivity
val accountManager = remember {
AccountManager(context)
}
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(true) {
coroutineScope.launch {
val result = accountManager.getCredentials()
when (result) {
is AccountManagerResult.FoundCredentials -> {
val (emailAddress, password) = result
}
else -> {}
}
}
}
val _credentialManager = CredentialManager.create(context)
val toastMessages = viewModel.toastMessages.collectAsState("")
LaunchedEffect(toastMessages.value) {

View file

@ -1,6 +1,7 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.login
import android.util.Log
import androidx.credentials.CreatePasswordRequest
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -31,9 +32,10 @@ class LoginScreenViewModel @Inject constructor(
private val _isEmailAddressPristine = MutableStateFlow(true)
private val _emailAddress = MutableStateFlow("")
val emailAddress = _emailAddress.asStateFlow()
val isValidEmailAddress = _isEmailAddressPristine.combine(_emailAddress.map { it.isNotBlank() }) { isPristine, isValid ->
isPristine || isValid
}
val isValidEmailAddress =
_isEmailAddressPristine.combine(_emailAddress.map { it.isNotBlank() }) { isPristine, isValid ->
isPristine || isValid
}
private val _isPasswordPristine = MutableStateFlow(true)
private val _password = MutableStateFlow("")

View file

@ -1,8 +1,6 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
import android.annotation.SuppressLint
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@ -32,8 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -50,37 +46,33 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialException
import androidx.hilt.navigation.compose.hiltViewModel
import ing.bikeshedengineer.debtpirate.domain.model.AccountManagerResult
import ing.bikeshedengineer.debtpirate.domain.repository.AccountManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable()
fun RegistrationScreen(
viewModel: RegistrationScreenViewModel = hiltViewModel<RegistrationScreenViewModel>()
) {
val context = LocalContext.current as ComponentActivity
val accountManager = remember { AccountManager(context) }
val localContext = LocalContext.current
val onRegistrationMessage = viewModel.onRegistrationComplete.collectAsState(null)
LaunchedEffect(onRegistrationMessage.value) {
val message = onRegistrationMessage.value
if (message != null) {
val (username, password) = message
val credentialManager = CredentialManager.create(localContext)
val createPasswordRequest = CreatePasswordRequest(id = username, password = password)
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(true) {
coroutineScope.launch {
viewModel.onRegistrationComplete.collect { credentials ->
val (emailAddress, password) = credentials
val result = accountManager.storeCredentials(emailAddress, password)
if (result !is AccountManagerResult.Success) {
Log.i(
"RegistrationScreen",
"Not able to store credentials in CredentialManager"
)
}
viewModel.onAction(RegistrationScreenAction.ResetFields)
viewModel.navigateToConfirmationScreen(emailAddress)
try {
credentialManager.createCredential(localContext, createPasswordRequest)
Log.d("RegistrationScreen", "Successfully stored login credentials")
} catch (err: CreateCredentialException) {
// TODO: Display a toast...
}
viewModel.navigateToConfirmationScreen(username)
}
}

View file

@ -1,5 +1,6 @@
package ing.bikeshedengineer.debtpirate.app.screen.auth.presentation.register
import androidx.credentials.CreatePasswordRequest
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -32,11 +33,7 @@ class RegistrationScreenViewModel @Inject constructor(
fun navigateToConfirmationScreen(emailAddress: String) {
viewModelScope.launch {
navigator.navigate(Destination.AuthRegistrationConfirmation(emailAddress)) {
popUpTo(Destination.AuthLogin) {
inclusive = true
}
}
navigator.navigate(Destination.AuthRegistrationConfirmation(emailAddress))
}
}
@ -118,7 +115,7 @@ class RegistrationScreenViewModel @Inject constructor(
val result =
submitAccountRegistrationRequest(emailAddress, name, confirmPassword)
val (userId, expiresAt, sessionToken) = result
val (_, expiresAt, sessionToken) = result
updateStoreData(
userId = result.userId,

View file

@ -0,0 +1,31 @@
package ing.bikeshedengineer.debtpirate.app.screen.home.presentation.overview
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable()
fun OverviewScreen() {
Scaffold(
modifier = Modifier.fillMaxSize()
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
)
{
OverviewComponent()
}
}
}
@Composable
fun OverviewComponent() {
Text("Hello, world!")
}

View file

@ -1,10 +1,25 @@
package ing.bikeshedengineer.debtpirate.data.repository
import ing.bikeshedengineer.debtpirate.data.remote.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.endpoint.AuthEndpoint
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
import retrofit2.Retrofit
interface AuthRepository {
suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): Response<ApiResponse<AuthLoginPostResponse>>
}
class AuthRepositoryImpl(private val httpClient: Retrofit) : AuthRepository {
private val authEndpoint: AuthEndpoint = this.httpClient.create(AuthEndpoint::class.java)
override suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): Response<ApiResponse<AuthLoginPostResponse>> {
return withContext(Dispatchers.IO) {
val response = authEndpoint.submitAuthLoginRequest(credentials)
return@withContext response
}
}
}

View file

@ -1,11 +1,59 @@
package ing.bikeshedengineer.debtpirate.data.repository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import ing.bikeshedengineer.debtpirate.data.remote.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.endpoint.UserEndpoint
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserCreatePostRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserCreatePostResponse
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Retrofit
interface UserRepository {
suspend fun createUserPostRequest(request: UserCreatePostRequest): UserCreatePostResponse
suspend fun verifyUserGetRequest(request: UserVerificationGetRequest): UserVerificationGetResponse
}
class UserRepositoryImpl(httpClient: Retrofit) : UserRepository {
private val userEndpoint: UserEndpoint = httpClient.create(UserEndpoint::class.java)
override suspend fun createUserPostRequest(request: UserCreatePostRequest): UserCreatePostResponse {
return withContext(Dispatchers.IO) {
val response = userEndpoint.createUserPostRequest(request)
if (response.isSuccessful) {
val body = response.body()
return@withContext body!!.data!!
} else {
val gson = Gson()
val errorType = object : TypeToken<ApiResponse<Unit>>() {}.type
val body =
gson.fromJson<ApiResponse<Unit>>(response.errorBody()!!.charStream(), errorType)
throw Throwable(body!!.error!!)
}
}
}
override suspend fun verifyUserGetRequest(request: UserVerificationGetRequest): UserVerificationGetResponse {
return withContext(Dispatchers.IO) {
val (userId, verificationToken) = request
val response = userEndpoint.userVerificationGetRequest(userId, verificationToken)
if (response.isSuccessful) {
val body = response.body()
return@withContext body!!.data!!
} else {
val gson = Gson()
val errorType = object : TypeToken<ApiResponse<Unit>>() {}.type
val body =
gson.fromJson<ApiResponse<Unit>>(response.errorBody()!!.charStream(), errorType)
throw Throwable(body!!.error!!)
}
}
}
}

View file

@ -6,6 +6,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ing.bikeshedengineer.debtpirate.AppDataStore
import ing.bikeshedengineer.debtpirate.domain.usecase.GetStoredTokensUseCase
import ing.bikeshedengineer.debtpirate.domain.usecase.UpdateStoreDataUseCase
import javax.inject.Singleton
@ -16,4 +17,8 @@ object UseCaseModule {
@Provides
@Singleton
fun provideUpdateStoreDataUseCase(store: DataStore<AppDataStore>) = UpdateStoreDataUseCase(store)
@Provides
@Singleton
fun provideGetStoredTokensUseCase(store: DataStore<AppDataStore>) = GetStoredTokensUseCase(store)
}

View file

@ -6,7 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import ing.bikeshedengineer.debtpirate.data.repository.AuthRepository
import ing.bikeshedengineer.debtpirate.domain.repository.AuthRepositoryImpl
import ing.bikeshedengineer.debtpirate.data.repository.AuthRepositoryImpl
import retrofit2.Retrofit
@Module

View file

@ -6,7 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
import ing.bikeshedengineer.debtpirate.data.repository.UserRepository
import ing.bikeshedengineer.debtpirate.domain.repository.UserRepositoryImpl
import ing.bikeshedengineer.debtpirate.data.repository.UserRepositoryImpl
import retrofit2.Retrofit
@Module

View file

@ -1,74 +0,0 @@
package ing.bikeshedengineer.debtpirate.domain.repository
import android.app.Activity
import android.util.Log
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPasswordOption
import androidx.credentials.PasswordCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
import ing.bikeshedengineer.debtpirate.domain.model.AccountManagerResult
class AccountManager(private val context: Activity) {
private val credentialManager = CredentialManager.create(context)
suspend fun storeCredentials(
emailAddress: String,
password: String
): AccountManagerResult {
return try {
credentialManager.createCredential(
context, request = CreatePasswordRequest(
id = emailAddress,
password
)
)
AccountManagerResult.Success
} catch (err: CreateCredentialNoCreateOptionException) {
Log.w(
"AccountManager",
"Cannot store credentials; a Google account isn't associated with this device"
)
AccountManagerResult.Unavailable
} catch (_: CreateCredentialCancellationException) {
AccountManagerResult.Canceled
} catch (err: CreateCredentialException) {
err.printStackTrace()
Log.i(
"AccountManager",
"Unable to store credentials: ${err.message}"
)
AccountManagerResult.Failure
}
}
suspend fun getCredentials(): AccountManagerResult {
return try {
val result = credentialManager.getCredential(
context, request = GetCredentialRequest(
credentialOptions = listOf(GetPasswordOption(isAutoSelectAllowed = true))
)
)
val credentials = result.credential
when (credentials) {
is PasswordCredential -> {
val emailAddress = credentials.id
val password = credentials.password
AccountManagerResult.FoundCredentials(emailAddress, password)
}
else -> AccountManagerResult.Invalid
}
} catch (err: Exception) {
AccountManagerResult.Failure
}
}
}

View file

@ -1,22 +0,0 @@
package ing.bikeshedengineer.debtpirate.domain.repository
import ing.bikeshedengineer.debtpirate.data.remote.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.endpoint.AuthEndpoint
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginPostResponse
import ing.bikeshedengineer.debtpirate.data.repository.AuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
import retrofit2.Retrofit
class AuthRepositoryImpl(private val httpClient: Retrofit) : AuthRepository {
private val authEndpoint: AuthEndpoint = this.httpClient.create(AuthEndpoint::class.java)
override suspend fun submitAuthLoginRequest(credentials: AuthLoginPostRequest): Response<ApiResponse<AuthLoginPostResponse>> {
return withContext(Dispatchers.IO) {
val response = authEndpoint.submitAuthLoginRequest(credentials)
return@withContext response
}
}
}

View file

@ -1,56 +0,0 @@
package ing.bikeshedengineer.debtpirate.domain.repository
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import ing.bikeshedengineer.debtpirate.data.remote.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.endpoint.UserEndpoint
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserCreatePostRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserCreatePostResponse
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.user.UserVerificationGetResponse
import ing.bikeshedengineer.debtpirate.data.repository.UserRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Retrofit
class UserRepositoryImpl(httpClient: Retrofit) : UserRepository {
private val userEndpoint: UserEndpoint = httpClient.create(UserEndpoint::class.java)
override suspend fun createUserPostRequest(request: UserCreatePostRequest): UserCreatePostResponse {
return withContext(Dispatchers.IO) {
val response = userEndpoint.createUserPostRequest(request)
if (response.isSuccessful) {
val body = response.body()
return@withContext body!!.data!!
} else {
val gson = Gson()
val errorType = object : TypeToken<ApiResponse<Unit>>() {}.type
val body =
gson.fromJson<ApiResponse<Unit>>(response.errorBody()!!.charStream(), errorType)
throw Throwable(body!!.error!!)
}
}
}
override suspend fun verifyUserGetRequest(request: UserVerificationGetRequest): UserVerificationGetResponse {
return withContext(Dispatchers.IO) {
val (userId, verificationToken) = request
val response = userEndpoint.userVerificationGetRequest(userId, verificationToken)
if (response.isSuccessful) {
val body = response.body()
return@withContext body!!.data!!
} else {
val gson = Gson()
val errorType = object : TypeToken<ApiResponse<Unit>>() {}.type
val body =
gson.fromJson<ApiResponse<Unit>>(response.errorBody()!!.charStream(), errorType)
throw Throwable(body!!.error!!)
}
}
}
}

View file

@ -0,0 +1,42 @@
package ing.bikeshedengineer.debtpirate.domain.usecase
import androidx.datastore.core.DataStore
import ing.bikeshedengineer.debtpirate.AppDataStore
import ing.bikeshedengineer.debtpirate.domain.model.Token
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
class GetStoredTokensUseCase(private val store: DataStore<AppDataStore>) {
operator fun invoke(): Flow<Pair<Token, Token?>?> {
return store.data.map { store ->
if (store.isInitialized) {
val now = ZonedDateTime.now()
val authTokenExpiration = ZonedDateTime.ofInstant(Instant.ofEpochSecond(store.authToken.expiresAt), ZoneOffset.UTC)
if (authTokenExpiration.isBefore(now)) {
return@map null
}
val sessionTokenExpiration = ZonedDateTime.ofInstant(Instant.ofEpochSecond(store.sessionToken.expiresAt), ZoneOffset.UTC)
return@map Pair(
Token(
store.authToken.token,
authTokenExpiration
),
if (sessionTokenExpiration.isBefore(now)) {
null
} else {
Token(
store.sessionToken.token,
sessionTokenExpiration
)
}
)
} else {
return@map null
}
}
}
}

View file

@ -4,7 +4,7 @@ import androidx.datastore.core.DataStore
import ing.bikeshedengineer.debtpirate.AppDataStore
import java.time.OffsetDateTime
class UpdateStoreDataUseCase(val store: DataStore<AppDataStore>) {
class UpdateStoreDataUseCase(private val store: DataStore<AppDataStore>) {
suspend operator fun invoke(
userId: Int? = null,
authToken: String? = null,

View file

@ -24,4 +24,11 @@ sealed interface Destination {
val userId: Int,
val verificationToken: String
) : Destination
/* Home Destinations */
@Serializable
data object HomeGraph : Destination
@Serializable
data object HomeOverview : Destination
}

View file

@ -1,3 +1,8 @@
<resources>
<string name="app_name">Debt Pirate</string>
<string name="asset_statements" translatable="false">
[{
\"include\": \"https://debtpirate.app/.well-known/assetlinks.json\"
}]
</string>
</resources>

View file

@ -2,12 +2,13 @@
activityCompose = "1.9.3"
agp = "8.7.3"
appcompat = "1.7.0"
composeBom = "2024.11.00"
biometric = "1.4.0-alpha02"
composeBom = "2024.12.01"
coreKtx = "1.15.0"
datastore = "1.1.1"
espressoCore = "3.6.1"
hilt = "2.51.1"
iconsExtended = "1.7.5"
iconsExtended = "1.7.6"
junit = "4.13.2"
junitVersion = "1.2.1"
kotlin = "2.0.10"
@ -17,19 +18,20 @@ lifecycleRuntimeKtx = "2.8.7"
lifecycleViewModelKtx = "2.8.7"
lifecycleViewmodelCompose = "2.8.7"
material = "1.12.0"
material3 = "1.4.0-alpha04"
navigation = "2.8.4"
material3 = "1.4.0-alpha05"
navigation = "2.8.5"
protobuf = "0.9.4"
protoLite = "3.21.11"
okhttp = "4.10.0"
retrofit = "2.9.0"
hiltNavigationCompose = "1.2.0"
fonts = "1.7.5"
fonts = "1.7.6"
credentialManager = "1.5.0-beta01"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-credentials-core = { group = "androidx.credentials", name = "credentials", version.ref = "credentialManager" }