Handle login errors

This commit is contained in:
Z. Charles Dziura 2024-10-27 09:45:46 -04:00
parent 29893f9ee7
commit 511a36516b
17 changed files with 68 additions and 37 deletions

View file

@ -1,13 +1,11 @@
package ing.bikeshedengineer.debtpirate package ing.bikeshedengineer.debtpirate
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />

View file

@ -8,8 +8,8 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import ing.bikeshedengineer.debtpirate.presentation.ui.auth.AuthScreen
import ing.bikeshedengineer.debtpirate.presentation.theme.DebtPirateTheme import ing.bikeshedengineer.debtpirate.presentation.theme.DebtPirateTheme
import ing.bikeshedengineer.debtpirate.presentation.ui.auth.AuthScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@ -27,7 +27,7 @@ class MainActivity : ComponentActivity() {
DebtPirateTheme { DebtPirateTheme {
NavHost(navController = navController, startDestination = AuthRoute) { NavHost(navController = navController, startDestination = AuthRoute) {
composable<AuthRoute> { composable<AuthRoute> {
AuthScreen( ) AuthScreen()
} }
} }
} }

View file

@ -3,9 +3,9 @@ package ing.bikeshedengineer.debtpirate.data.remote.endpoint
import ing.bikeshedengineer.debtpirate.data.remote.model.ApiResponse import ing.bikeshedengineer.debtpirate.data.remote.model.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginResponse import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginResponse
import retrofit2.http.POST
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST
interface AuthEndpoint { interface AuthEndpoint {
@POST("auth/login") @POST("auth/login")

View file

@ -1,3 +1,3 @@
package ing.bikeshedengineer.debtpirate.data.remote.model package ing.bikeshedengineer.debtpirate.data.remote.model
data class ApiResponse<T>(val data: T?, val err: Any?) data class ApiResponse<T>(val data: T?, val error: String?)

View file

@ -1,5 +1,9 @@
package ing.bikeshedengineer.debtpirate.data.remote.model.auth package ing.bikeshedengineer.debtpirate.data.remote.model.auth
data class AuthLoginResponse(val userId: Int, val session: AuthLoginTokenData, val auth: AuthLoginTokenData) data class AuthLoginResponse(
val userId: Int,
val session: AuthLoginTokenData,
val auth: AuthLoginTokenData
)
data class AuthLoginTokenData(val token: String, val expiresAt: String) data class AuthLoginTokenData(val token: String, val expiresAt: String)

View file

@ -1,9 +1,8 @@
package ing.bikeshedengineer.debtpirate.data.remote.repository package ing.bikeshedengineer.debtpirate.data.remote.repository
import ing.bikeshedengineer.debtpirate.data.remote.model.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginResponse import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginResponse
interface AuthRepository { interface AuthRepository {
suspend fun submitLoginRequest(credentials: AuthLoginRequest): Result<AuthLoginResponse> suspend fun submitLoginRequest(credentials: AuthLoginRequest): AuthLoginResponse
} }

View file

@ -4,13 +4,9 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import ing.bikeshedengineer.debtpirate.BuildConfig
import ing.bikeshedengineer.debtpirate.data.remote.repository.AuthRepository import ing.bikeshedengineer.debtpirate.data.remote.repository.AuthRepository
import ing.bikeshedengineer.debtpirate.domain.repository.AuthRepositoryImpl import ing.bikeshedengineer.debtpirate.domain.repository.AuthRepositoryImpl
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module

View file

@ -21,11 +21,12 @@ object HttpClientModule {
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder() .client(
.addInterceptor(HttpLoggingInterceptor().apply { OkHttpClient.Builder()
level = HttpLoggingInterceptor.Level.BODY .addInterceptor(HttpLoggingInterceptor().apply {
}) level = HttpLoggingInterceptor.Level.BODY
.build() })
.build()
) )
.build() .build()
} }

View file

@ -4,5 +4,8 @@ import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter.ISO_INSTANT import java.time.format.DateTimeFormatter.ISO_INSTANT
data class Token(val token: String, val expiresAt: ZonedDateTime) { data class Token(val token: String, val expiresAt: ZonedDateTime) {
constructor(token: String, expiresAtStr: String): this(token, ZonedDateTime.parse(expiresAtStr, ISO_INSTANT)) constructor(token: String, expiresAtStr: String) : this(
token,
ZonedDateTime.parse(expiresAtStr, ISO_INSTANT)
)
} }

View file

@ -1,5 +1,7 @@
package ing.bikeshedengineer.debtpirate.domain.repository package ing.bikeshedengineer.debtpirate.domain.repository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import ing.bikeshedengineer.debtpirate.data.remote.endpoint.AuthEndpoint import ing.bikeshedengineer.debtpirate.data.remote.endpoint.AuthEndpoint
import ing.bikeshedengineer.debtpirate.data.remote.model.ApiResponse import ing.bikeshedengineer.debtpirate.data.remote.model.ApiResponse
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest
@ -8,21 +10,24 @@ import ing.bikeshedengineer.debtpirate.data.remote.repository.AuthRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import retrofit2.Retrofit import retrofit2.Retrofit
import kotlin.Result
class AuthRepositoryImpl constructor(private val httpClient: Retrofit): AuthRepository { class AuthRepositoryImpl(private val httpClient: Retrofit) : AuthRepository {
private val authEndpoint: AuthEndpoint = this.httpClient.create(AuthEndpoint::class.java) private val authEndpoint: AuthEndpoint = this.httpClient.create(AuthEndpoint::class.java)
override suspend fun submitLoginRequest(credentials: AuthLoginRequest): Result<AuthLoginResponse> { override suspend fun submitLoginRequest(credentials: AuthLoginRequest): AuthLoginResponse {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val response = authEndpoint.submitLoginRequest(credentials) val response = authEndpoint.submitLoginRequest(credentials)
if (response.isSuccessful) { if (response.isSuccessful) {
val body = response.body() val body = response.body()
return@withContext Result.success(body?.data!!) return@withContext body!!.data!!
} else { } else {
val error = response.errorBody() val gson = Gson()
return@withContext Result.failure(Throwable(error.toString())) val errorType = object : TypeToken<ApiResponse<Unit>>() {}.type
val body =
gson.fromJson<ApiResponse<Unit>>(response.errorBody()!!.charStream(), errorType)
throw Throwable(body!!.error!!)
} }
} }
} }

View file

@ -94,7 +94,7 @@ private fun LoginComponent(
leadingIcon = { Icon(Icons.Outlined.Person, "person") }, leadingIcon = { Icon(Icons.Outlined.Person, "person") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words, capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next imeAction = ImeAction.Next
), ),
@ -117,7 +117,8 @@ private fun LoginComponent(
onSend = { submitLoginRequest() } onSend = { submitLoginRequest() }
), ),
onValueChange = onUpdatePassword, onValueChange = onUpdatePassword,
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(top = 4.dp)) .padding(PaddingValues(top = 4.dp))
) )

View file

@ -1,18 +1,24 @@
package ing.bikeshedengineer.debtpirate.presentation.ui.auth package ing.bikeshedengineer.debtpirate.presentation.ui.auth
import android.util.Log import android.util.Log
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import ing.bikeshedengineer.debtpirate.PrefsDataStore
import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest import ing.bikeshedengineer.debtpirate.data.remote.model.auth.AuthLoginRequest
import ing.bikeshedengineer.debtpirate.data.remote.repository.AuthRepository import ing.bikeshedengineer.debtpirate.data.remote.repository.AuthRepository
import ing.bikeshedengineer.debtpirate.domain.model.Token
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthScreenViewModel @Inject constructor(private val authRepository: AuthRepository) : ViewModel() { class AuthScreenViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val prefsStore: DataStore<PrefsDataStore>
) : ViewModel() {
private val tag: String private val tag: String
get() { get() {
return javaClass.simpleName return javaClass.simpleName
@ -46,4 +52,24 @@ class AuthScreenViewModel @Inject constructor(private val authRepository: AuthRe
} }
} }
} }
fun storeAuthData(userId: Int, sessionToken: Token, authToken: Token) {
viewModelScope.launch {
prefsStore.updateData { currentPrefs ->
val updatedSessionToken = currentPrefs.sessionToken.toBuilder()
.setToken(sessionToken.token)
.setExpiresAt(sessionToken.expiresAt.toEpochSecond())
val updatedAuthToken = currentPrefs.authToken.toBuilder()
.setToken(authToken.token)
.setExpiresAt(authToken.expiresAt.toEpochSecond())
currentPrefs.toBuilder()
.setUserId(userId)
.setSessionToken(updatedSessionToken)
.setAuthToken(updatedAuthToken)
.build()
}
}
}
} }

View file

@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Theme.DebtPirate" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <style name="Theme.DebtPirate" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. --> <!-- Primary brand color. -->

View file

@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Theme.DebtPirate" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <style name="Theme.DebtPirate" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. --> <!-- Primary brand color. -->

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<!--Set application-wide security config using base-config tag.--> <!--Set application-wide security config using base-config tag.-->
<base-config cleartextTrafficPermitted="true"/> <base-config cleartextTrafficPermitted="true" />
</network-security-config> </network-security-config>

View file

@ -1,8 +1,7 @@
package ing.bikeshedengineer.debtpirate package ing.bikeshedengineer.debtpirate
import org.junit.Test
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).