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
+1
View File
@@ -4,6 +4,7 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'androidx.navigation.safeargs.kotlin' id 'androidx.navigation.safeargs.kotlin'
id 'dagger.hilt.android.plugin' id 'dagger.hilt.android.plugin'
id 'org.jetbrains.kotlin.plugin.serialization'
} }
android { android {
+2
View File
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="gq.kirmanak.mealie"> package="gq.kirmanak.mealie">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".MealieApp" android:name=".MealieApp"
android:allowBackup="true" android:allowBackup="true"
@@ -0,0 +1,7 @@
package gq.kirmanak.mealie
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MealieApp : Application()
@@ -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()
}
}
@@ -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>
}
@@ -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)
}
}
@@ -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
}
@@ -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?
}
@@ -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
}
}
@@ -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
}
@@ -0,0 +1,6 @@
package gq.kirmanak.mealie.data.auth
interface AuthStorage {
suspend fun isAuthenticated(): Boolean
suspend fun storeToken(token: String)
}
@@ -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()
}
}
@@ -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
)
@@ -1,18 +1,32 @@
package gq.kirmanak.mealie.ui.auth package gq.kirmanak.mealie.ui.auth
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.EditText
import android.widget.Toast
import androidx.fragment.app.Fragment 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 dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding
private const val TAG = "AuthenticationFragment"
@AndroidEntryPoint @AndroidEntryPoint
class AuthenticationFragment : Fragment() { class AuthenticationFragment : Fragment() {
private var _binding: FragmentAuthenticationBinding? = null private var _binding: FragmentAuthenticationBinding? = null
private val binding: FragmentAuthenticationBinding private val binding: FragmentAuthenticationBinding
get() = checkNotNull(_binding) { "Binding requested when fragment is off screen" } 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -23,6 +37,63 @@ class AuthenticationFragment : Fragment() {
return binding.root 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
@@ -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)
}
}
@@ -6,15 +6,64 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.auth.AuthenticationFragment"> tools:context=".ui.auth.AuthenticationFragment">
<!-- TODO implement correct authentication screen --> <com.google.android.material.textfield.TextInputLayout
<TextView android:id="@+id/email_input_layout"
android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="wrap_content"
android:gravity="center" app:layout_constraintBottom_toTopOf="@+id/password_input_layout"
android:text="@string/app_name" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toBottomOf="@+id/url_input_layout" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
+4
View File
@@ -1,3 +1,7 @@
<resources> <resources>
<string name="app_name">Mealie</string> <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> </resources>
+3 -1
View File
@@ -2,13 +2,15 @@
buildscript { buildscript {
ext.nav_version = "2.3.5" ext.nav_version = "2.3.5"
ext.hilt_version = "2.38.1" ext.hilt_version = "2.38.1"
ext.kotlin_version = "1.5.31"
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:7.0.3" classpath "com.android.tools.build:gradle:7.0.3"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"