Implement initial authentication flow
This commit is contained in:
@@ -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,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"
|
||||||
|
|||||||
7
app/src/main/java/gq/kirmanak/mealie/MealieApp.kt
Normal file
7
app/src/main/java/gq/kirmanak/mealie/MealieApp.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package gq.kirmanak.mealie
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MealieApp : Application()
|
||||||
20
app/src/main/java/gq/kirmanak/mealie/data/RetrofitBuilder.kt
Normal file
20
app/src/main/java/gq/kirmanak/mealie/data/RetrofitBuilder.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/src/main/java/gq/kirmanak/mealie/data/auth/AuthModule.kt
Normal file
21
app/src/main/java/gq/kirmanak/mealie/data/auth/AuthModule.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user