Merge pull request #39 from kirmanak/authentication
Fix expiring tokens
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@ data class VersionInfo(
|
||||
val production: Boolean,
|
||||
val version: String,
|
||||
val demoStatus: Boolean,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package gq.kirmanak.mealient.data.baseurl
|
||||
package gq.kirmanak.mealient.data.baseurl.impl
|
||||
|
||||
import retrofit2.http.GET
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user