Implement initial authentication flow

This commit is contained in:
Kirill Kamakin
2021-11-07 11:49:52 +03:00
parent 3b83aa4e15
commit b0a53b5991
18 changed files with 322 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ plugins {
id 'com.android.application'
id 'androidx.navigation.safeargs.kotlin'
id 'dagger.hilt.android.plugin'
id 'org.jetbrains.kotlin.plugin.serialization'
}
android {

View File

@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="gq.kirmanak.mealie">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MealieApp"
android:allowBackup="true"

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealie
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MealieApp : Application()

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealie.data
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType
import retrofit2.Retrofit
import javax.inject.Inject
@ExperimentalSerializationApi
class RetrofitBuilder @Inject constructor() {
fun buildRetrofit(baseUrl: String): Retrofit {
val url = if (baseUrl.startsWith("http")) baseUrl else "https://$baseUrl"
val contentType = MediaType.get("application/json")
return Retrofit.Builder()
.baseUrl(url)
.addConverterFactory(Json.asConverterFactory(contentType))
.build()
}
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealie.data.auth
interface AuthDataSource {
/**
* Tries to acquire authentication token using the provided credentials on specified server.
*/
suspend fun authenticate(username: String, password: String, baseUrl: String): Result<String>
}

View File

@@ -0,0 +1,25 @@
package gq.kirmanak.mealie.data.auth
import gq.kirmanak.mealie.data.RetrofitBuilder
import kotlinx.serialization.ExperimentalSerializationApi
import retrofit2.create
import javax.inject.Inject
@ExperimentalSerializationApi
class AuthDataSourceImpl @Inject constructor(
private val retrofitBuilder: RetrofitBuilder
) : AuthDataSource {
override suspend fun authenticate(
username: String,
password: String,
baseUrl: String
): Result<String> {
val authService = retrofitBuilder.buildRetrofit(baseUrl).create<AuthService>()
val response = try {
authService.getToken(username, password)
} catch (e: Exception) {
return Result.failure(e)
}
return Result.success(response.accessToken)
}
}

View File

@@ -0,0 +1,21 @@
package gq.kirmanak.mealie.data.auth
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import kotlinx.serialization.ExperimentalSerializationApi
@ExperimentalSerializationApi
@Module
@InstallIn(ViewModelComponent::class)
abstract class AuthModule {
@Binds
abstract fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource
@Binds
abstract fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
@Binds
abstract fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
}

View File

@@ -0,0 +1,7 @@
package gq.kirmanak.mealie.data.auth
interface AuthRepo {
suspend fun isAuthenticated(): Boolean
suspend fun authenticate(username: String, password: String, baseUrl: String): Throwable?
}

View File

@@ -0,0 +1,22 @@
package gq.kirmanak.mealie.data.auth
import javax.inject.Inject
class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource,
private val storage: AuthStorage
) : AuthRepo {
override suspend fun isAuthenticated(): Boolean = storage.isAuthenticated()
override suspend fun authenticate(
username: String,
password: String,
baseUrl: String
): Throwable? {
val authResult = dataSource.authenticate(username, password, baseUrl)
if (authResult.isFailure) return authResult.exceptionOrNull()
val token = checkNotNull(authResult.getOrNull())
storage.storeToken(token)
return null
}
}

View File

@@ -0,0 +1,18 @@
package gq.kirmanak.mealie.data.auth
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface AuthService {
@FormUrlEncoded
@POST("/api/auth/token")
suspend fun getToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String? = null,
@Field("scope") scope: String? = null,
@Field("client_id") clientId: String? = null,
@Field("client_secret") clientSecret: String? = null
): GetTokenResponse
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealie.data.auth
interface AuthStorage {
suspend fun isAuthenticated(): Boolean
suspend fun storeToken(token: String)
}

View File

@@ -0,0 +1,24 @@
package gq.kirmanak.mealie.data.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val TOKEN_KEY = "AUTH_TOKEN"
class AuthStorageImpl @Inject constructor(@ApplicationContext private val context: Context) : AuthStorage {
private val sharedPreferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(context)
override suspend fun isAuthenticated(): Boolean = withContext(Dispatchers.IO) {
sharedPreferences.getString(TOKEN_KEY, null) != null
}
override suspend fun storeToken(token: String) {
sharedPreferences.edit().putString(TOKEN_KEY, token).apply()
}
}

View File

@@ -0,0 +1,10 @@
package gq.kirmanak.mealie.data.auth
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("token_type") val tokenType: String
)

View File

@@ -1,18 +1,32 @@
package gq.kirmanak.mealie.ui.auth
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding
private const val TAG = "AuthenticationFragment"
@AndroidEntryPoint
class AuthenticationFragment : Fragment() {
private var _binding: FragmentAuthenticationBinding? = null
private val binding: FragmentAuthenticationBinding
get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" }
private val viewModel by viewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkIfAuthenticatedAlready()
}
override fun onCreateView(
inflater: LayoutInflater,
@@ -23,6 +37,63 @@ class AuthenticationFragment : Fragment() {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.button.setOnClickListener {
onLoginClicked()
}
}
private fun checkIfAuthenticatedAlready() {
lifecycleScope.launchWhenCreated {
Toast.makeText(
requireContext(),
if (viewModel.isAuthenticated()) "User is authenticated"
else "User isn't authenticated",
Toast.LENGTH_SHORT
).show()
}
}
private fun onLoginClicked() {
val email: String
val pass: String
val url: String
with(binding) {
email = checkIfInputIsEmpty(emailInput, emailInputLayout) {
"Email is empty"
} ?: return
pass = checkIfInputIsEmpty(passwordInput, passwordInputLayout) {
"Pass is empty"
} ?: return
url = checkIfInputIsEmpty(urlInput, urlInputLayout) {
"URL is empty"
} ?: return
}
lifecycleScope.launchWhenResumed {
val exception = viewModel.authenticate(email, pass, url)
Log.e(TAG, "onLoginClicked: ", exception)
Toast.makeText(
requireContext(),
"Exception is ${exception?.message ?: "null"}",
Toast.LENGTH_SHORT
).show()
}
}
private fun checkIfInputIsEmpty(
input: EditText,
inputLayout: TextInputLayout,
errorText: () -> String
): String? {
val text = input.text?.toString()
if (text.isNullOrBlank()) {
inputLayout.error = errorText()
return null
}
return text
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null

View File

@@ -0,0 +1,17 @@
package gq.kirmanak.mealie.ui.auth
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealie.data.auth.AuthRepo
import javax.inject.Inject
@HiltViewModel
class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo
) : ViewModel() {
suspend fun isAuthenticated(): Boolean = authRepo.isAuthenticated()
suspend fun authenticate(username: String, password: String, baseUrl: String): Throwable? {
return authRepo.authenticate(username, password, baseUrl)
}
}

View File

@@ -6,15 +6,64 @@
android:layout_height="match_parent"
tools:context=".ui.auth.AuthenticationFragment">
<!-- TODO implement correct authentication screen -->
<TextView
android:id="@+id/textView"
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/email_input_layout"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:text="@string/app_name"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/password_input_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/email_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/fragment_authentication_input_hint_email"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/password_input_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/url_input_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/email_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/fragment_authentication_input_hint_password"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/url_input_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/password_input_layout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/url_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/fragment_authnetication_input_hint_url"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/fragment_authentication_button_login"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,3 +1,7 @@
<resources>
<string name="app_name">Mealie</string>
<string name="fragment_authentication_input_hint_email">E-mail</string>
<string name="fragment_authentication_input_hint_password">Password</string>
<string name="fragment_authnetication_input_hint_url">Url</string>
<string name="fragment_authentication_button_login">Login</string>
</resources>