Merge pull request #39 from kirmanak/authentication

Fix expiring tokens
This commit is contained in:
Kirill Kamakin
2022-04-08 23:19:19 +05:00
committed by GitHub
48 changed files with 591 additions and 345 deletions

View File

@@ -154,6 +154,9 @@ dependencies {
// https://developer.android.com/topic/libraries/architecture/datastore
implementation "androidx.datastore:datastore-preferences:1.0.0"
// https://developer.android.com/topic/security/data#include-library
implementation "androidx.security:security-crypto:1.0.0"
// https://github.com/junit-team/junit4/releases
testImplementation "junit:junit:4.13.2"

View File

@@ -17,7 +17,7 @@
tools:ignore="UnusedAttribute"
android:theme="@style/Theme.Mealient">
<activity
android:name=".MainActivity"
android:name=".ui.activity.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -6,11 +6,13 @@ interface AuthRepo {
val isAuthorizedFlow: Flow<Boolean>
suspend fun authenticate(username: String, password: String)
suspend fun authenticate(email: String, password: String)
suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String
suspend fun logout()
suspend fun invalidateAuthHeader()
}

View File

@@ -6,9 +6,15 @@ interface AuthStorage {
val authHeaderFlow: Flow<String?>
suspend fun storeAuthData(authHeader: String)
suspend fun setAuthHeader(authHeader: String?)
suspend fun getAuthHeader(): String?
suspend fun clearAuthData()
suspend fun setEmail(email: String?)
suspend fun getEmail(): String?
suspend fun setPassword(password: String?)
suspend fun getPassword(): String?
}

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
@@ -11,28 +12,41 @@ import javax.inject.Singleton
@Singleton
class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource,
private val storage: AuthStorage,
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean>
get() = storage.authHeaderFlow.map { it != null }
get() = authStorage.authHeaderFlow.map { it != null }
override suspend fun authenticate(username: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password")
val accessToken = dataSource.authenticate(username, password)
Timber.d("authenticate result is \"$accessToken\"")
storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken))
override suspend fun authenticate(email: String, password: String) {
Timber.v("authenticate() called with: email = $email, password = $password")
authDataSource.authenticate(email, password)
.let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) }
authStorage.setEmail(email)
authStorage.setPassword(password)
}
override suspend fun getAuthHeader(): String? = storage.getAuthHeader()
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
"Auth header is null when it was required"
}
override suspend fun logout() {
Timber.v("logout() called")
storage.clearAuthData()
authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null)
}
override suspend fun invalidateAuthHeader() {
Timber.v("invalidateAuthHeader() called")
val email = authStorage.getEmail() ?: return
val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }
.onFailure { logout() } // Clear all known values to avoid reusing them
}
companion object {

View File

@@ -1,37 +1,66 @@
package gq.kirmanak.mealient.data.auth.impl
import androidx.datastore.preferences.core.Preferences
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.di.AuthModule.Companion.ENCRYPTED
import gq.kirmanak.mealient.extensions.prefsChangeFlow
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class AuthStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
@Named(ENCRYPTED) private val sharedPreferences: SharedPreferences,
) : AuthStorage {
private val authHeaderKey: Preferences.Key<String>
get() = preferencesStorage.authHeaderKey
override val authHeaderFlow: Flow<String?>
get() = preferencesStorage.valueUpdates(authHeaderKey)
get() = sharedPreferences
.prefsChangeFlow { getString(AUTH_HEADER_KEY, null) }
.distinctUntilChanged()
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
override suspend fun storeAuthData(authHeader: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader")
preferencesStorage.storeValues(Pair(authHeaderKey, authHeader))
override suspend fun setAuthHeader(authHeader: String?) = putString(AUTH_HEADER_KEY, authHeader)
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY)
override suspend fun setEmail(email: String?) = putString(EMAIL_KEY, email)
override suspend fun getEmail(): String? = getString(EMAIL_KEY)
override suspend fun setPassword(password: String?) = putString(PASSWORD_KEY, password)
override suspend fun getPassword(): String? = getString(PASSWORD_KEY)
private suspend fun putString(
key: String,
value: String?
) = withContext(singleThreadDispatcher) {
Timber.v("putString() called with: key = $key, value = $value")
sharedPreferences.edit(commit = true) { putString(key, value) }
}
override suspend fun getAuthHeader(): String? {
Timber.v("getAuthHeader() called")
val token = preferencesStorage.getValue(authHeaderKey)
Timber.d("getAuthHeader: header is \"$token\"")
return token
private suspend fun getString(key: String) = withContext(singleThreadDispatcher) {
val result = sharedPreferences.getString(key, null)
Timber.v("getString() called with: key = $key, returned: $result")
result
}
override suspend fun clearAuthData() {
Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey)
companion object {
@VisibleForTesting
const val AUTH_HEADER_KEY = "authHeader"
@VisibleForTesting
const val EMAIL_KEY = "email"
@VisibleForTesting
const val PASSWORD_KEY = "password"
}
}
}

View File

@@ -4,4 +4,4 @@ data class VersionInfo(
val production: Boolean,
val version: String,
val demoStatus: Boolean,
)
)

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.data.baseurl
package gq.kirmanak.mealient.data.baseurl.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import javax.inject.Inject
import javax.inject.Singleton

View File

@@ -1,5 +1,7 @@
package gq.kirmanak.mealient.data.baseurl
package gq.kirmanak.mealient.data.baseurl.impl
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.mapToNetworkError
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.baseurl
package gq.kirmanak.mealient.data.baseurl.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -11,4 +11,4 @@ data class VersionResponse(
val version: String,
@SerialName("demoStatus")
val demoStatus: Boolean,
)
)

View File

@@ -1,4 +1,4 @@
package gq.kirmanak.mealient.data.baseurl
package gq.kirmanak.mealient.data.baseurl.impl
import retrofit2.http.GET

View File

@@ -0,0 +1,43 @@
package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthenticationInterceptor @Inject constructor(
private val authRepo: AuthRepo,
) : Interceptor {
private val authHeader: String?
get() = runBlocking { authRepo.getAuthHeader() }
override fun intercept(chain: Interceptor.Chain): Response {
val currentHeader = authHeader ?: return chain.proceed(chain.request())
val response = proceedWithAuthHeader(chain, currentHeader)
return if (listOf(401, 403).contains(response.code)) {
runBlocking { authRepo.invalidateAuthHeader() }
// Try again with new auth header (if any) or return previous response
authHeader?.let { proceedWithAuthHeader(chain, it) } ?: response
} else {
response
}
}
private fun proceedWithAuthHeader(
chain: Interceptor.Chain,
authHeader: String,
) = chain.proceed(
chain.request()
.newBuilder()
.header(HEADER_NAME, authHeader)
.build()
)
companion object {
private const val HEADER_NAME = "Authorization"
}
}

View File

@@ -1,21 +0,0 @@
package gq.kirmanak.mealient.data.network
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
class OkHttpBuilder
@Inject
constructor(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>
) {
fun buildOkHttp(): OkHttpClient {
Timber.v("buildOkHttp() called")
return OkHttpClient.Builder()
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
}
}

View File

@@ -7,11 +7,8 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RetrofitBuilder @Inject constructor(
class RetrofitBuilder(
private val okHttpClient: OkHttpClient,
private val json: Json
) {

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
@@ -10,20 +9,19 @@ import javax.inject.Singleton
@Singleton
class RecipeDataSourceImpl @Inject constructor(
private val authRepo: AuthRepo,
private val recipeServiceFactory: ServiceFactory<RecipeService>,
) : RecipeDataSource {
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
val recipeSummary = getRecipeService().getRecipeSummary(start, limit, getToken())
val recipeSummary = getRecipeService().getRecipeSummary(start, limit)
Timber.v("requestRecipes() returned: $recipeSummary")
return recipeSummary
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
Timber.v("requestRecipeInfo() called with: slug = $slug")
val recipeInfo = getRecipeService().getRecipe(slug, getToken())
val recipeInfo = getRecipeService().getRecipe(slug)
Timber.v("requestRecipeInfo() returned: $recipeInfo")
return recipeInfo
}
@@ -32,6 +30,4 @@ class RecipeDataSourceImpl @Inject constructor(
Timber.v("getRecipeService() called")
return recipeServiceFactory.provideService()
}
private suspend fun getToken(): String? = authRepo.getAuthHeader()
}

View File

@@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
@@ -12,12 +11,10 @@ interface RecipeService {
suspend fun getRecipeSummary(
@Query("start") start: Int,
@Query("limit") limit: Int,
@Header("Authorization") authHeader: String?,
): List<GetRecipeSummaryResponse>
@GET("/api/recipes/{recipe_slug}")
suspend fun getRecipe(
@Path("recipe_slug") recipeSlug: String,
@Header("Authorization") authHeader: String?,
): GetRecipeResponse
}

View File

@@ -7,8 +7,6 @@ interface PreferencesStorage {
val baseUrlKey: Preferences.Key<String>
val authHeaderKey: Preferences.Key<String>
val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
suspend fun <T> getValue(key: Preferences.Key<T>): T?

View File

@@ -17,8 +17,6 @@ class PreferencesStorageImpl @Inject constructor(
override val baseUrlKey = stringPreferencesKey("baseUrl")
override val authHeaderKey = stringPreferencesKey("authHeader")
override val isDisclaimerAcceptedKey = booleanPreferencesKey("isDisclaimedAccepted")
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {

View File

@@ -1,9 +1,15 @@
package gq.kirmanak.mealient.di
import android.accounts.AccountManager
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
@@ -16,6 +22,9 @@ import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -23,13 +32,40 @@ import javax.inject.Singleton
interface AuthModule {
companion object {
const val ENCRYPTED = "encrypted"
@Provides
@Singleton
fun provideAuthServiceFactory(
retrofitBuilder: RetrofitBuilder,
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(baseURLStorage)
): ServiceFactory<AuthService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
@Provides
@Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager {
return AccountManager.get(context)
}
@Provides
@Singleton
@Named(ENCRYPTED)
fun provideEncryptedSharedPreferences(
@ApplicationContext applicationContext: Context,
): SharedPreferences {
val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC
val mainKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)
return EncryptedSharedPreferences.create(
ENCRYPTED,
mainKeyAlias,
applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
@Binds
@@ -38,9 +74,9 @@ interface AuthModule {
@Binds
@Singleton
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
@Binds
@Singleton
fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
}

View File

@@ -5,10 +5,17 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.baseurl.*
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -20,9 +27,12 @@ interface BaseURLModule {
@Provides
@Singleton
fun provideVersionServiceFactory(
retrofitBuilder: RetrofitBuilder,
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> = retrofitBuilder.createServiceFactory(baseURLStorage)
): ServiceFactory<VersionService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
}
@Binds

View File

@@ -4,19 +4,41 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.network.OkHttpBuilder
import gq.kirmanak.mealient.data.network.AuthenticationInterceptor
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
const val AUTH_OK_HTTP = "auth"
const val NO_AUTH_OK_HTTP = "noauth"
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun createOkHttp(okHttpBuilder: OkHttpBuilder): OkHttpClient =
okHttpBuilder.buildOkHttp()
@Named(AUTH_OK_HTTP)
fun createAuthOkHttp(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
interceptors: Set<@JvmSuppressWildcards Interceptor>,
authenticationInterceptor: AuthenticationInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authenticationInterceptor)
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
@Provides
@Singleton
@Named(NO_AUTH_OK_HTTP)
fun createNoAuthOkHttp(
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
interceptors: Set<@JvmSuppressWildcards Interceptor>,
): OkHttpClient = OkHttpClient.Builder()
.apply { for (interceptor in interceptors) addNetworkInterceptor(interceptor) }
.build()
@Provides
@Singleton

View File

@@ -19,6 +19,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeService
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
import javax.inject.Singleton
@Module
@@ -46,9 +49,12 @@ interface RecipeModule {
@Provides
@Singleton
fun provideRecipeServiceFactory(
retrofitBuilder: RetrofitBuilder,
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> = retrofitBuilder.createServiceFactory(baseURLStorage)
): ServiceFactory<RecipeService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
}
@Provides
@Singleton

View File

@@ -1,36 +1,12 @@
package gq.kirmanak.mealient.extensions
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.addCallback
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
fun Fragment.executeOnceOnBackPressed(action: () -> Unit) {
val onBackPressedDispatcher = requireActivity().onBackPressedDispatcher
lifecycleScope.launch {
onBackPressedDispatcher.backPressedFlow().first()
action()
onBackPressedDispatcher.onBackPressed() // Execute other callbacks now
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun OnBackPressedDispatcher.backPressedFlow(): Flow<Unit> = callbackFlow {
val callback = addCallback { trySend(Unit) }
awaitClose {
callback.isEnabled = false
callback.remove()
}
}
inline fun <T> Fragment.collectWithViewLifecycle(
flow: Flow<T>,
crossinline collector: suspend (T) -> Unit,
) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) }
) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) }

View File

@@ -1,7 +1,7 @@
package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.baseurl.VersionResponse
import gq.kirmanak.mealient.data.baseurl.impl.VersionResponse
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeInstructionEntity

View File

@@ -1,6 +1,7 @@
package gq.kirmanak.mealient.extensions
import android.app.Activity
import android.content.SharedPreferences
import android.os.Build
import android.view.View
import android.view.WindowInsets
@@ -106,4 +107,15 @@ fun EditText.checkIfInputIsEmpty(
suspend fun EditText.waitUntilNotEmpty() {
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> SharedPreferences.prefsChangeFlow(
valueReader: SharedPreferences.() -> T,
): Flow<T> = callbackFlow {
fun sendValue() = trySend(valueReader()).logErrors("prefsChangeFlow")
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> sendValue() }
sendValue()
registerOnSharedPreferenceChangeListener(listener)
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
}

View File

@@ -1,24 +1,26 @@
package gq.kirmanak.mealient
package gq.kirmanak.mealient.ui.activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.navigation.findNavController
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationState.AUTHORIZED
import gq.kirmanak.mealient.ui.auth.AuthenticationState.UNAUTHORIZED
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import timber.log.Timber
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: MainActivityBinding
private val authViewModel by viewModels<AuthenticationViewModel>()
private val viewModel by viewModels<MainActivityViewModel>()
private var lastAuthenticationState: AuthenticationState? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -51,7 +53,7 @@ class MainActivity : AppCompatActivity() {
private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called")
authViewModel.authenticationStateLive.observe(this, ::onAuthStateUpdate)
viewModel.authenticationStateLive.observe(this, ::onAuthStateUpdate)
}
private fun onAuthStateUpdate(authState: AuthenticationState) {
@@ -71,13 +73,21 @@ class MainActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
Timber.v("onOptionsItemSelected() called with: item = $item")
val result = when (item.itemId) {
R.id.logout, R.id.login -> {
// When user clicks logout they don't want to be authorized
authViewModel.authRequested = item.itemId == R.id.login
R.id.login -> {
navigateToLogin()
true
}
R.id.logout -> {
viewModel.logout()
true
}
else -> super.onOptionsItemSelected(item)
}
return result
}
private fun navigateToLogin() {
Timber.v("navigateToLogin() called")
findNavController(binding.navHost.id).navigate("mealient://authenticate".toUri())
}
}

View File

@@ -0,0 +1,36 @@
package gq.kirmanak.mealient.ui.activity
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
private val authRepo: AuthRepo,
) : ViewModel() {
private val showLoginButtonFlow = MutableStateFlow(false)
var showLoginButton: Boolean by showLoginButtonFlow::value
private val authenticationStateFlow = combine(
showLoginButtonFlow,
authRepo.isAuthorizedFlow,
AuthenticationState::determineState
)
val authenticationStateLive: LiveData<AuthenticationState>
get() = authenticationStateFlow.asLiveData()
fun logout() {
Timber.v("logout() called")
viewModelScope.launch { authRepo.logout() }
}
}

View File

@@ -4,8 +4,7 @@ import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
@@ -13,20 +12,12 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.executeOnceOnBackPressed
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by activityViewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
executeOnceOnBackPressed { viewModel.authRequested = false }
}
private val viewModel by viewModels<AuthenticationViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -34,6 +25,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
binding.button.setOnClickListener { onLoginClicked() }
(requireActivity() as? AppCompatActivity)?.supportActionBar?.title =
getString(R.string.app_name)
viewModel.authenticationResult.observe(viewLifecycleOwner, ::onAuthenticationResult)
}
private fun onLoginClicked(): Unit = with(binding) {
@@ -53,9 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
) ?: return
button.isClickable = false
viewLifecycleOwner.lifecycleScope.launch {
onAuthenticationResult(viewModel.authenticate(email, pass))
}
viewModel.authenticate(email, pass)
}
private fun onAuthenticationResult(result: Result<Unit>) {

View File

@@ -4,22 +4,19 @@ import timber.log.Timber
enum class AuthenticationState {
AUTHORIZED,
AUTH_REQUESTED,
UNAUTHORIZED,
UNKNOWN;
HIDDEN;
companion object {
fun determineState(
isLoginRequested: Boolean,
showLoginButton: Boolean,
isAuthorized: Boolean,
): AuthenticationState {
Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
Timber.v("determineState() called with: showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
val result = when {
!showLoginButton -> UNKNOWN
!showLoginButton -> HIDDEN
isAuthorized -> AUTHORIZED
isLoginRequested -> AUTH_REQUESTED
else -> UNAUTHORIZED
}
Timber.v("determineState() returned: $result")

View File

@@ -1,15 +1,12 @@
package gq.kirmanak.mealient.ui.auth
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -19,31 +16,16 @@ class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo,
) : ViewModel() {
private val authRequestsFlow = MutableStateFlow(false)
private val showLoginButtonFlow = MutableStateFlow(false)
private val authenticationStateFlow = combine(
authRequestsFlow,
showLoginButtonFlow,
authRepo.isAuthorizedFlow,
AuthenticationState::determineState
)
val authenticationStateLive: LiveData<AuthenticationState>
get() = authenticationStateFlow.asLiveData()
var authRequested: Boolean by authRequestsFlow::value
var showLoginButton: Boolean by showLoginButtonFlow::value
private val _authenticationResult = MutableLiveData<Result<Unit>>()
val authenticationResult: LiveData<Result<Unit>>
get() = _authenticationResult
init {
fun authenticate(email: String, password: String) {
Timber.v("authenticate() called with: email = $email, password = $password")
viewModelScope.launch {
authRequestsFlow.collect { isRequested ->
// Clear auth token on logout request
if (!isRequested) authRepo.logout()
_authenticationResult.value = runCatchingExceptCancel {
authRepo.authenticate(email, password)
}
}
}
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
authRepo.authenticate(username, password)
}.onFailure {
Timber.e(it, "authenticate: can't authenticate")
}
}

View File

@@ -22,8 +22,8 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
binding.button.setOnClickListener(::onProceedClick)
viewModel.checkURLResult.observe(viewLifecycleOwner, ::onCheckURLResult)
}
private fun onProceedClick(view: View) {
@@ -36,13 +36,13 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
viewModel.saveBaseUrl(url)
}
private fun updateState(baseURLScreenState: BaseURLScreenState) {
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState")
if (baseURLScreenState.navigateNext) {
private fun onCheckURLResult(result: Result<Unit>) {
Timber.v("onCheckURLResult() called with: result = $result")
if (result.isSuccess) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
return
}
binding.urlInputLayout.error = when (val exception = baseURLScreenState.error) {
binding.urlInputLayout.error = when (val exception = result.exceptionOrNull()) {
is NetworkError.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
is NetworkError.MalformedUrl -> {

View File

@@ -1,8 +0,0 @@
package gq.kirmanak.mealient.ui.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
data class BaseURLScreenState(
val error: NetworkError? = null,
val navigateNext: Boolean = false,
)

View File

@@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -18,14 +18,8 @@ class BaseURLViewModel @Inject constructor(
private val versionDataSource: VersionDataSource,
) : ViewModel() {
private val _screenState = MutableLiveData(BaseURLScreenState())
var currentScreenState: BaseURLScreenState
get() = _screenState.value!!
private set(value) {
_screenState.value = value
}
val screenState: LiveData<BaseURLScreenState>
get() = _screenState
private val _checkURLResult = MutableLiveData<Result<Unit>>()
val checkURLResult: LiveData<Result<Unit>> get() = _checkURLResult
fun saveBaseUrl(baseURL: String) {
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
@@ -36,17 +30,13 @@ class BaseURLViewModel @Inject constructor(
private suspend fun checkBaseURL(baseURL: String) {
Timber.v("checkBaseURL() called with: baseURL = $baseURL")
val version = try {
val result = runCatchingExceptCancel {
// If it returns proper version info then it must be a Mealie
versionDataSource.getVersionInfo(baseURL)
} catch (e: NetworkError) {
Timber.e(e, "checkBaseURL: can't get version info")
currentScreenState = BaseURLScreenState(e, false)
return
baseURLStorage.storeBaseURL(baseURL)
}
Timber.d("checkBaseURL: version is $version")
baseURLStorage.storeBaseURL(baseURL)
currentScreenState = BaseURLScreenState(null, true)
Timber.i("checkBaseURL: result is $result")
_checkURLResult.value = result
}
companion object {

View File

@@ -5,10 +5,7 @@ import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@@ -18,11 +15,12 @@ import javax.inject.Inject
class DisclaimerViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage
) : ViewModel() {
val isAccepted: LiveData<Boolean>
get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData()
private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC)
val okayCountDown: LiveData<Int> = _okayCountDown
private var isCountDownStarted = false
fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called")
@@ -31,9 +29,12 @@ class DisclaimerViewModel @Inject constructor(
fun startCountDown() {
Timber.v("startCountDown() called")
if (isCountDownStarted) return
isCountDownStarted = true
tickerFlow(COUNT_DOWN_TICK_PERIOD_SEC.toLong(), TimeUnit.SECONDS)
.take(FULL_COUNT_DOWN_SEC - COUNT_DOWN_TICK_PERIOD_SEC + 1)
.onEach { _okayCountDown.value = FULL_COUNT_DOWN_SEC - it }
.onCompletion { isCountDownStarted = false }
.launchIn(viewModelScope)
}

View File

@@ -5,15 +5,17 @@ import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.BuildConfig
import gq.kirmanak.mealient.di.AUTH_OK_HTTP
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class PicassoBuilder @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient
@Named(AUTH_OK_HTTP) private val okHttpClient: OkHttpClient
) {
fun buildPicasso(): Picasso {

View File

@@ -14,33 +14,19 @@ import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle
import gq.kirmanak.mealient.extensions.refreshRequestFlow
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import timber.log.Timber
@AndroidEntryPoint
class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind)
private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by activityViewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
authViewModel.authenticationStateLive.observe(this, ::onAuthStateChange)
}
private fun onAuthStateChange(authenticationState: AuthenticationState) {
Timber.v("onAuthStateChange() called with: authenticationState = $authenticationState")
if (authenticationState == AuthenticationState.AUTH_REQUESTED) {
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
}
}
private val activityViewModel by activityViewModels<MainActivityViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
authViewModel.showLoginButton = true
activityViewModel.showLoginButton = true
setupRecipeAdapter()
(requireActivity() as? AppCompatActivity)?.supportActionBar?.title = null
}
@@ -78,6 +64,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
Timber.v("onDestroyView() called")
// Prevent RV leaking through mObservers list in adapter
binding.recipes.adapter = null
authViewModel.showLoginButton = false
activityViewModel.showLoginButton = false
}
}

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
tools:context=".ui.activity.MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_holder"

View File

@@ -9,15 +9,16 @@
android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication" />
tools:layout="@layout/fragment_authentication">
<deepLink
android:id="@+id/deepLink"
app:uri="mealient://authenticate" />
</fragment>
<fragment
android:id="@+id/recipesFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
android:label="fragment_recipes"
tools:layout="@layout/fragment_recipes">
<action
android:id="@+id/action_recipesFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment" />
<action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" />

View File

@@ -12,18 +12,20 @@
<string name="fragment_recipe_info_ingredients_header">Ingredients</string>
<string name="fragment_recipe_info_instructions_header">Instructions</string>
<string name="fragment_disclaimer_main_text">This project is developed independently from the core Mealie project. It is NOT associated with the core Mealie developers. Any issues must be reported to the Mealient repository, NOT the Mealie repository.</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_baseurl_url_input_empty">URL can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_base_url_no_connection">Can\'t connect, check address.</string>
<string name="fragment_base_url_unexpected_response">Unexpected response. Is it Mealie?</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="fragment_base_url_malformed_url">Check URL format: %s</string>
<string name="fragment_base_url_save">Proceed</string>
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
<string name="menu_main_toolbar_content_description_login" translatable="false">@string/menu_main_toolbar_login</string>
<string name="menu_main_toolbar_login">Login</string>
<string name="fragment_disclaimer_button_okay">Okay</string>
<string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_authentication_unknown_error">Something went wrong, please try again.</string>
<string name="account_type" translatable="false">Mealient</string>
<string name="auth_token_type" translatable="false">mealientAuthToken</string>
</resources>

View File

@@ -32,7 +32,7 @@ class AuthDataSourceImplTest {
fun setUp() {
MockKAnnotations.init(this)
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
coEvery { authServiceFactory.provideService() } returns authService
coEvery { authServiceFactory.provideService(any()) } returns authService
}
@Test
@@ -71,7 +71,9 @@ class AuthDataSourceImplTest {
@Test(expected = MalformedUrl::class)
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
coEvery { authServiceFactory.provideService() } throws MalformedUrl(RuntimeException())
coEvery {
authServiceFactory.provideService(any())
} throws MalformedUrl(RuntimeException())
callAuthenticate()
}

View File

@@ -2,26 +2,23 @@ package gq.kirmanak.mealient.data.auth.impl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AuthRepoImplTest : RobolectricTest() {
class AuthRepoImplTest {
@MockK
lateinit var dataSource: AuthDataSource
@@ -29,50 +26,83 @@ class AuthRepoImplTest : RobolectricTest() {
@MockK(relaxUnitFun = true)
lateinit var storage: AuthStorage
lateinit var subject: AuthRepoImpl
lateinit var subject: AuthRepo
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthRepoImpl(dataSource, storage)
subject = AuthRepoImpl(storage, dataSource)
}
@Test
fun `when not authenticated then first auth status is false`() = runTest {
coEvery { storage.authHeaderFlow } returns flowOf(null)
assertThat(subject.isAuthorizedFlow.first()).isFalse()
fun `when isAuthorizedFlow then reads from storage`() = runTest {
every { storage.authHeaderFlow } returns flowOf("", null, "header")
assertThat(subject.isAuthorizedFlow.toList()).isEqualTo(listOf(true, false, true))
}
@Test
fun `when authenticated then first auth status is true`() = runTest {
coEvery { storage.authHeaderFlow } returns flowOf(TEST_AUTH_HEADER)
assertThat(subject.isAuthorizedFlow.first()).isTrue()
}
@Test(expected = Unauthorized::class)
fun `when authentication fails then authenticate throws`() = runTest {
coEvery {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
} throws Unauthorized(RuntimeException())
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
}
@Test
fun `when authenticated then getToken returns token`() = runTest {
coEvery { storage.getAuthHeader() } returns TEST_AUTH_HEADER
assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
}
@Test
fun `when authenticated successfully then stores token and url`() = runTest {
fun `when authenticate successfully then saves to storage`() = runTest {
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
subject.authenticate(TEST_USERNAME, TEST_PASSWORD)
coVerify { storage.storeAuthData(TEST_AUTH_HEADER) }
coVerifyAll {
storage.setAuthHeader(TEST_AUTH_HEADER)
storage.setEmail(TEST_USERNAME)
storage.setPassword(TEST_PASSWORD)
}
confirmVerified(storage)
}
@Test
fun `when logout then clearAuthData is called`() = runTest {
subject.logout()
coVerify { storage.clearAuthData() }
fun `when authenticate fails then does not change storage`() = runTest {
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException()
runCatching { subject.authenticate("invalid", "") }
confirmVerified(storage)
}
}
@Test
fun `when logout then removes email, password and header`() = runTest {
subject.logout()
coVerifyAll {
storage.setEmail(null)
storage.setPassword(null)
storage.setAuthHeader(null)
}
confirmVerified(storage)
}
@Test
fun `when invalidate then does not authenticate without email`() = runTest {
coEvery { storage.getEmail() } returns null
coEvery { storage.getPassword() } returns TEST_PASSWORD
subject.invalidateAuthHeader()
confirmVerified(dataSource)
}
@Test
fun `when invalidate then does not authenticate without password`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns null
subject.invalidateAuthHeader()
confirmVerified(dataSource)
}
@Test
fun `when invalidate with credentials then calls authenticate`() = runTest {
coEvery { storage.getEmail() } returns TEST_USERNAME
coEvery { storage.getPassword() } returns TEST_PASSWORD
coEvery { dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD)) } returns TEST_TOKEN
subject.invalidateAuthHeader()
coVerifyAll {
dataSource.authenticate(eq(TEST_USERNAME), eq(TEST_PASSWORD))
}
}
@Test
fun `when invalidate with credentials and auth fails then clears email`() = runTest {
coEvery { storage.getEmail() } returns "invalid"
coEvery { storage.getPassword() } returns ""
coEvery { dataSource.authenticate(any(), any()) } throws RuntimeException()
subject.invalidateAuthHeader()
coVerify { storage.setEmail(null) }
}
}

View File

@@ -1,12 +1,23 @@
package gq.kirmanak.mealient.data.auth.impl
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.AUTH_HEADER_KEY
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.EMAIL_KEY
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl.Companion.PASSWORD_KEY
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_PASSWORD
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealient.test.HiltRobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
@@ -15,46 +26,39 @@ import javax.inject.Inject
class AuthStorageImplTest : HiltRobolectricTest() {
@Inject
lateinit var subject: AuthStorageImpl
@ApplicationContext
lateinit var context: Context
@Test
fun `when storing auth data then doesn't throw`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER)
lateinit var subject: AuthStorage
lateinit var sharedPreferences: SharedPreferences
@Before
fun setUp() {
sharedPreferences = context.getSharedPreferences("test", Context.MODE_PRIVATE)
subject = AuthStorageImpl(sharedPreferences)
}
@Test
fun `when reading token after storing data then returns token`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER)
assertThat(subject.getAuthHeader()).isEqualTo(TEST_AUTH_HEADER)
}
@Test
fun `when reading token without storing data then returns null`() = runTest {
assertThat(subject.getAuthHeader()).isNull()
}
@Test
fun `when didn't store auth data then first token is null`() = runTest {
assertThat(subject.authHeaderFlow.first()).isNull()
}
@Test
fun `when stored auth data then first token is correct`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER)
fun `when authHeaderFlow is observed then sends value immediately`() = runTest {
sharedPreferences.edit(commit = true) { putString(AUTH_HEADER_KEY, TEST_AUTH_HEADER) }
assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER)
}
@Test
fun `when clearAuthData then first token is null`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER)
subject.clearAuthData()
assertThat(subject.authHeaderFlow.first()).isNull()
fun `when authHeader is observed then sends null if nothing saved`() = runTest {
assertThat(subject.authHeaderFlow.first()).isEqualTo(null)
}
@Test
fun `when clearAuthData then getToken returns null`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER)
subject.clearAuthData()
assertThat(subject.getAuthHeader()).isNull()
fun `when setEmail then edits shared preferences`() = runTest {
subject.setEmail(TEST_USERNAME)
assertThat(sharedPreferences.getString(EMAIL_KEY, null)).isEqualTo(TEST_USERNAME)
}
@Test
fun `when getPassword then reads shared preferences`() = runTest {
sharedPreferences.edit(commit = true) { putString(PASSWORD_KEY, TEST_PASSWORD) }
assertThat(subject.getPassword()).isEqualTo(TEST_PASSWORD)
}
}

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.data.baseurl
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.impl.BaseURLStorageImpl
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import io.mockk.MockKAnnotations
import io.mockk.coEvery

View File

@@ -1,6 +1,9 @@
package gq.kirmanak.mealient.data.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.impl.VersionDataSourceImpl
import gq.kirmanak.mealient.data.baseurl.impl.VersionResponse
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL

View File

@@ -0,0 +1,121 @@
package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import io.mockk.*
import io.mockk.impl.annotations.MockK
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Before
import org.junit.Test
class AuthenticationInterceptorTest {
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK
lateinit var chain: Interceptor.Chain
lateinit var subject: AuthenticationInterceptor
@Before
fun setUp() {
MockKAnnotations.init(this)
subject = AuthenticationInterceptor(authRepo)
}
@Test
fun `when intercept without header then response without header`() {
val request = createRequest()
val response = createResponse(request)
every { chain.request() } returns request
every { chain.proceed(any()) } returns response
coEvery { authRepo.getAuthHeader() } returns null
assertThat(subject.intercept(chain)).isEqualTo(response)
}
@Test
fun `when intercept with header then chain called with header`() {
val request = createRequest()
val response = createResponse(request)
val requestSlot = slot<Request>()
every { chain.request() } returns request
every { chain.proceed(capture(requestSlot)) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.intercept(chain)
assertThat(requestSlot.captured.header("Authorization")).isEqualTo(TEST_AUTH_HEADER)
}
@Test
fun `when intercept with stale header then calls invalidate`() {
val request = createRequest()
val response = createResponse(request, code = 403)
every { chain.request() } returns request
every { chain.proceed(any()) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.intercept(chain)
coVerifySequence {
authRepo.getAuthHeader()
authRepo.invalidateAuthHeader()
authRepo.getAuthHeader()
}
}
@Test
fun `when intercept with proper header then requests auth header once`() {
val request = createRequest()
val response = createResponse(request)
every { chain.request() } returns request
every { chain.proceed(any()) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER
subject.intercept(chain)
coVerifySequence { authRepo.getAuthHeader() }
}
@Test
fun `when intercept with stale header then updates header`() {
val request = createRequest()
val response = createResponse(request, code = 403)
val requests = mutableListOf<Request>()
every { chain.request() } returns request
every { chain.proceed(capture(requests)) } returns response
coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER andThen "Bearer NEW TOKEN"
subject.intercept(chain)
assertThat(requests.size).isEqualTo(2)
assertThat(requests[0].header("Authorization")).isEqualTo(TEST_AUTH_HEADER)
assertThat(requests[1].header("Authorization")).isEqualTo("Bearer NEW TOKEN")
}
private fun createRequest(
url: String = TEST_BASE_URL,
): Request = Request.Builder()
.url(url)
.build()
private fun createResponse(
request: Request,
code: Int = 200,
): Response = Response.Builder()
.protocol(Protocol.HTTP_2)
.code(code)
.request(request)
.message("Doesn't matter")
.build()
}

View File

@@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.network
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionService
import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import io.mockk.*
import io.mockk.impl.annotations.MockK

View File

@@ -18,30 +18,30 @@ class PreferencesStorageImplTest : HiltRobolectricTest() {
@Test
fun `when getValue without writes then null`() = runTest {
assertThat(subject.getValue(subject.authHeaderKey)).isNull()
assertThat(subject.getValue(subject.baseUrlKey)).isNull()
}
@Test(expected = IllegalStateException::class)
fun `when requireValue without writes then throws IllegalStateException`() = runTest {
subject.requireValue(subject.authHeaderKey)
subject.requireValue(subject.baseUrlKey)
}
@Test
fun `when getValue after write then returns value`() = runTest {
subject.storeValues(Pair(subject.authHeaderKey, "test"))
assertThat(subject.getValue(subject.authHeaderKey)).isEqualTo("test")
subject.storeValues(Pair(subject.baseUrlKey, "test"))
assertThat(subject.getValue(subject.baseUrlKey)).isEqualTo("test")
}
@Test
fun `when storeValue then valueUpdates emits`() = runTest {
subject.storeValues(Pair(subject.authHeaderKey, "test"))
assertThat(subject.valueUpdates(subject.authHeaderKey).first()).isEqualTo("test")
subject.storeValues(Pair(subject.baseUrlKey, "test"))
assertThat(subject.valueUpdates(subject.baseUrlKey).first()).isEqualTo("test")
}
@Test
fun `when remove value then getValue returns null`() = runTest {
subject.storeValues(Pair(subject.authHeaderKey, "test"))
subject.removeValues(subject.authHeaderKey)
assertThat(subject.getValue(subject.authHeaderKey)).isNull()
subject.storeValues(Pair(subject.baseUrlKey, "test"))
subject.removeValues(subject.baseUrlKey)
assertThat(subject.getValue(subject.baseUrlKey)).isNull()
}
}

View File

@@ -1,10 +1,8 @@
package gq.kirmanak.mealient.ui.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.RobolectricTest
import io.mockk.MockKAnnotations
@@ -34,35 +32,6 @@ class BaseURLViewModelTest : RobolectricTest() {
subject = BaseURLViewModel(baseURLStorage, versionDataSource)
}
@Test
fun `when initialized then error is null`() {
assertThat(subject.currentScreenState.error).isNull()
}
@Test
fun `when initialized then navigateNext is false`() {
assertThat(subject.currentScreenState.navigateNext).isFalse()
}
@Test
fun `when saveBaseUrl and getVersionInfo throws then state is correct`() = runTest {
val error = NetworkError.Unauthorized(RuntimeException())
coEvery { versionDataSource.getVersionInfo(eq(TEST_BASE_URL)) } throws error
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
assertThat(subject.currentScreenState).isEqualTo(BaseURLScreenState(error, false))
}
@Test
fun `when saveBaseUrl and getVersionInfo returns result then state is correct`() = runTest {
coEvery {
versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
} returns VersionInfo(true, "0.5.6", true)
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
assertThat(subject.currentScreenState).isEqualTo(BaseURLScreenState(null, true))
}
@Test
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
coEvery {