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 '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 {

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"

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 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

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" 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>

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>

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"